* fix: stderr/output hygiene rollup — diagnostics to stderr, colored labels, auth propagation
Component 1 (PR #485): Route triage 'no messages' and modelarmor error
bodies to stderr so stdout stays machine-readable.
Component 2 (PR #466): Add colored error[variant]: labels to stderr
on TTY, respecting NO_COLOR. Replace emoji hint with colorized text.
Component 3 (PR #446): Propagate auth errors as GwsError::Auth in
calendar, chat, docs, drive, script, sheets helpers instead of
silently proceeding unauthenticated. dry-run bypass preserved.
* fix: deduplicate accessNotConfigured stderr output
Use if/else so that accessNotConfigured errors get the specialized
hint guidance instead of redundantly printing both the generic summary
and the hint. Non-accessNotConfigured Api errors and all other variants
still get the generic error[variant]: summary line.
* test: remove misleading model_armor_post error format test
model_armor_post function. A proper integration test would require
HTTP mocking (e.g. mockito/wiremock) which is out of scope for this PR.
* refactor: deduplicate error printing else branches
Use early return in accessNotConfigured branch so the generic
eprintln! only appears once, eliminating the duplicated else blocks.
* security: sanitize error messages before printing to stderr
Add sanitize_for_terminal() to strip control characters (ANSI escape
sequences, bell, backspace, etc.) from error messages before printing
to stderr, preventing terminal escape injection from API responses.
Newlines and tabs are preserved for readability.
The function is pub(crate) so it can be reused by other modules that
print untrusted content to stderr.
* fix: sanitize all stderr error output across codebase
Apply sanitize_for_terminal() to all 16 remaining eprintln sites
that print unsanitized error strings to stderr. This prevents
terminal escape sequence injection through error messages.
Files updated:
- workflows.rs (4 sites)
- watch.rs (2 sites)
- gmail/mod.rs (3 sites)
- executor.rs (1 site)
- subscribe.rs (1 site)
- token_storage.rs (2 sites)
- credential_store.rs (2 sites)
- setup.rs (1 site)
- generate_skills.rs (1 site)
Also fixes clippy: map_err -> inspect_err where closure only logs.
---------
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
* fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths
- Cap Retry-After header at 60s to prevent hostile servers from hanging the CLI
- Extract compute_retry_delay() with saturating_pow for safe exponential backoff
- Sanitize mimeType by stripping control characters to prevent MIME header injection
- Add validate_safe_file_path() for --upload and --output path validation
- Gate --upload/--output through path validation in main.rs before any I/O
Consolidates security fixes from PRs #448 and #447.
* chore: regenerate skills [skip ci]
* fix: resolve mimeType sanitization bypass and document TOCTOU caveat
- Restructure resolve_upload_mime() using or_else chain so all code paths
go through control-char stripping (early returns were bypassing it)
- Document TOCTOU limitation in validate_safe_file_path() as known caveat
* fix: use canonicalized paths for I/O and normalize .. in non-existent suffix
Address review comments:
- main.rs: use canonicalized path from validate_safe_file_path for I/O
instead of discarding it (closes TOCTOU gap)
- validate.rs: add normalize_dotdot() to resolve .. components in
non-existent suffix (prevents traversal via doesnt_exist/../../etc/passwd)
- Add regression test for non-existent prefix traversal bypass
---------
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
* fix(validate): reject dangerous Unicode characters in input validation
Extend reject_control_chars() and validate_resource_name() to reject
zero-width chars (U+200B, U+200C, U+200D, U+FEFF), bidi overrides
(U+202A-U+202E), Unicode line/paragraph separators (U+2028, U+2029),
and directional isolates (U+2066-U+2069). These multi-byte codepoints
were silently passing the previous ASCII-range byte check, creating
a potential injection vector when the CLI is driven by LLM agents.
Adds 20 new tests covering all rejected categories plus documented
intentional pass-throughs (homoglyphs, overlong names).
* refactor(validate): replace slice const with is_rejected_unicode() fn using matches!
Switch from a REJECTED_UNICODE_CHARS &[char] constant + .contains() (O(M)
linear scan per character) to an is_rejected_unicode(c: char) -> bool helper
that uses the matches! macro with char ranges. This gives O(1) per character
and reads more clearly at call sites via .any(is_rejected_unicode).
* perf(validate): combine ASCII and Unicode checks into a single pass
Address review feedback: replace the two-iteration approach (one byte
scan + one char scan) in reject_control_chars with a single char loop,
and merge the separate is_control / is_rejected_unicode guards in
validate_resource_name into one any() call. Avoids iterating the input
string twice, closing the O(N*M) concern raised by the reviewer.
* feat: support google meet video conferencing in calendar +insert (#461, #419)
* feat: add unit tests for google meet and align dependencies
* chore: add changeset for google meet support
* style: cargo fmt
* chore: address PR feedback - restore ratatui 0.30.0 and clarify help text
* test: use robust assertions for google meet insert
* feat: make Google Meet requestId deterministic for idempotency
* fix: restore dependencies and make Google Meet requestId seed more robust
* fix: use JSON serialization for robust Google Meet requestId seed
* fix: improve error handling for Google Meet requestId seed serialization
* fix: ensure idempotency key seed structure matches request body
* fix: align seed_payload attendees structure with actual request body
- Use tempfile::NamedTempFile for synchronous atomic_write to ensure 0600 permissions at creation.
- Use tokio::fs::OpenOptions with mode(0o600) for asynchronous atomic_write_async.
- Remove redundant set_permissions calls in oauth_config.rs and credential_store.rs.
- Ensure tempfile is a regular dependency (was dev-dependency).
- Add tests to verify file permissions on Unix systems.
Fixes#401
Add encode_address_header() that parses mailbox lists, RFC 2047
encodes only the display-name portion of non-ASCII addresses, and
leaves email addresses untouched. Applied to all 4 address headers
(To, From, Cc, Bcc) in MessageBuilder::build().
Previously, only Subject got RFC 2047 encoding while address headers
only got CRLF sanitization, causing mojibake for non-ASCII names.
Supersedes #405, #458, #469. Closes#404.
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Replace machine-local chrono::Local and UTC epoch math with the
authenticated user's Google account timezone (Calendar Settings API).
- Add chrono-tz dependency for IANA timezone parsing
- New src/timezone.rs: resolve timezone with priority:
--timezone flag > 24h cache > Calendar API > local fallback
- calendar.rs: add --timezone/--tz flag to +agenda
- workflows.rs: fix +standup-report, +weekly-digest, +meeting-prep
- auth_commands.rs: invalidate timezone cache on logout
- Update README.md and AGENTS.md with timezone docs
Supersedes #369 and #462.
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
* fix: stream multipart uploads to avoid OOM on large files
Replace buffered file read + build_multipart_body in build_http_request
with streaming build_multipart_stream using tokio_util::io::ReaderStream.
Memory usage drops from O(file_size) to O(64 KB) regardless of upload size.
Content-Length is pre-computed from file metadata so Google APIs still
receive the correct header without buffering.
Fixes#244
* refactor: improve error messages per review feedback
- Metadata error now says 'Failed to get metadata' instead of misleading
'Failed to read upload file'
- File::open error in stream now includes the file path for easier debugging
* test: add Drive upload smoketest to CI
Uploads a small text file, verifies the response has a file ID,
then cleans up by deleting it. Validates the streaming multipart
upload path end-to-end against real Google APIs.
* fix(ci): use drive +upload helper for upload smoketest
The upload is via the +upload helper command, not files create --upload.
Also pipe stderr through tee so errors are visible in CI logs.
* revert: remove Drive upload smoketest (insufficient CI scopes)
---------
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
The multipart upload media Content-Type is now resolved independently
from the metadata mimeType, enabling Drive import conversions (e.g.
Markdown → Google Docs) to work automatically.
Priority order for the media MIME type:
1. --upload-content-type flag (explicit override)
2. File extension inference (best guess for what the bytes are)
3. Metadata mimeType (backward-compat fallback)
4. application/octet-stream
Previously the metadata mimeType was reused for the media part, which
meant uploading `notes.md` with mimeType set to
`application/vnd.google-apps.document` would incorrectly label the
bytes as a Google Doc instead of text/markdown.
Made-with: Cursor
Add validate_resource_name() to the --space argument in chat +send,
consistent with the validation already applied in gmail +watch,
events +subscribe, and modelarmor helpers. Prevents path traversal
and query injection via the space parameter.
triage.rs was the only helper file with zero test coverage.
Add tests for: default max (20), explicit max, non-numeric max
fallback, custom query, labels flag, and output format selection.
The People API exposes scopes like `contacts`, `contacts.readonly`,
and `directory.readonly`, none of which start with `people`. When
users ran `gws auth login -s people`, zero scopes matched because
`map_service_to_scope_prefix` returned `"people"` verbatim.
Change `map_service_to_scope_prefix` to `map_service_to_scope_prefixes`
returning a Vec to support services that map to multiple scope
prefixes. Add the `people` → `["contacts", "directory"]` mapping.
Chat scopes (chat.spaces, chat.messages) already matched correctly
since they share the `chat` prefix, but this is now verified by tests.
Closes#310Closes#316
* fix(calendar): use local timezone for agenda day boundaries
Previously, --today and --tomorrow computed day boundaries using UTC
epoch arithmetic, so after local midnight \!= UTC midnight the wrong
day's events were returned. Now uses chrono::Local to derive midnight
in the user's timezone.
Also fixes --today which had no explicit branch and fell through to
the generic "N days from now" path.
* fix: use earliest() for DST-safe local time resolution
Replace .single().unwrap_or(local_now) with .earliest().unwrap_or(local_now)
to correctly handle DST transitions where midnight may be ambiguous
or non-existent. Applied in both production code and test.
* fix(schema): expose repeated field and expand array query params
Two related fixes for repeated/array query parameters:
1. `gws schema` now includes `"repeated": true` in parameter output
when the Discovery Document marks a parameter as repeated. This
lets users know which params accept multiple values.
2. When `--params` contains a JSON array for a parameter marked
`repeated: true`, the executor now expands it into multiple
query parameters (e.g. `?h=Subject&h=Date&h=From`) instead of
stringifying the array as a single value.
The query_params type changes from HashMap<String, String> to
Vec<(String, String)> to support multiple entries with the same key.
Closes#300
* fix: warn when array is passed for a non-repeated query parameter
Address review feedback: print a warning to stderr when a JSON array
is provided for a parameter not marked as repeated, since the array
will be stringified rather than expanded. Directs users to gws schema
to check which parameters accept arrays.
* fix: import MethodParameter in executor test module
The test `test_build_url_repeated_query_param_expands_array` uses
MethodParameter but it was not imported in the test module's use
statement, causing a compilation error in CI.
* fix: pass all query params in a single request.query() call
Consolidate query parameters (including pageToken) into a single
request.query() call to ensure repeated parameters are sent correctly
instead of being overwritten by successive calls.
* fix(sheets): preserve multi-row structure in +append --json-values
Previously, `parse_append_args` called `.flatten()` on the parsed
`Vec<Vec<String>>`, collapsing all rows into a single flat vector.
Combined with `build_append_request` wrapping the flat vec in another
array, this meant `[["Alice","100"],["Bob","200"]]` produced one row
with four columns instead of two rows with two columns each.
Change `AppendConfig.values` from `Vec<String>` to `Vec<Vec<String>>`
so row boundaries are preserved end-to-end. Single-row inputs via
`--values` and flat JSON arrays are wrapped in a single-element outer
vec for consistency.
Closes#311
* chore: trigger CLA re-check
* fix: warn on malformed --json-values input instead of silently ignoring
Address review feedback: print a warning to stderr when --json-values
cannot be parsed as a JSON array, rather than silently falling back
to an empty value set.
* fix(auth): validate --subscription in gmail +watch and deduplicate PUBSUB_API_BASE
- Move PUBSUB_API_BASE constant to helpers/mod.rs (shared by events/subscribe and gmail/watch)
- Add validate_resource_name on --subscription in gmail +watch parse_watch_args
- Replace remaining hardcoded Pub/Sub and Gmail API URLs with constants
- Add test for --subscription path traversal rejection
Closes#408
* fix: replace remaining hardcoded Pub/Sub URLs in subscribe.rs with PUBSUB_API_BASE
---------
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
* Fix token refresh for long-running helpers
* Reuse tokens within a single loop iteration
Remove redundant access_token() calls for acknowledge requests (both
loops) and per-message Gmail fetches. The token obtained at the start
of the iteration / function is still valid and is now reused for all
HTTP calls within the same pass.
Addresses review feedback from gemini-code-assist.
* Address review: deduplicate FakeTokenProvider, refresh gmail token per message
- Move FakeTokenProvider to auth.rs behind #[cfg(test)] so both helper
test modules share one definition.
- Refresh the Gmail token per message inside fetch_and_output_messages
to guard against expiry during large batches.
- Update test expectations accordingly.
* Warn instead of printing misleading 'Cleanup complete' on token failure
Move the success message inside the token-refresh guard and add an else
branch that warns about potential orphaned resources.
* Reuse single gmail token per fetch_and_output_messages call
A single history batch won't approach the token's 1-hour lifetime,
so per-message refresh is unnecessary overhead. The outer watch_pull_loop
already refreshes tokens each iteration for long-running resilience.
* fix(auth): auto-recover from stale encrypted credentials after upgrade
When credentials.enc cannot be decrypted (e.g. after an upgrade that
changed the encryption key), automatically remove the stale file and
fall through to other credential sources (plaintext, ADC) instead of
hard-erroring. This breaks the stuck loop where logout+login couldn't
fix the issue.
Also sync .encryption_key file backup when the keyring has a valid key
but the file is missing, preventing future key loss if the keyring
becomes unavailable.
Fixes#389
* fix: log warnings instead of silently ignoring file operation errors
Address review feedback: log warnings when file removal or backup
creation fails, so users get clear feedback instead of silent failures.
Token cache cleanup now skips NotFound errors since they may not exist.
* refactor: use tokio::fs::remove_file for async consistency
Switch from std::fs::remove_file to tokio::fs::remove_file in the
async load_credentials_inner function to avoid blocking the runtime,
consistent with other file operations in the same function.
* refactor: reuse parse_credential_file and use atomic key backup
- Reuse parse_credential_file for encrypted creds instead of manual
JSON parsing. This removes duplication and adds ServiceAccount support.
- Use save_key_file_exclusive (atomic) for key backup creation, with
AlreadyExists race condition handling.
- Update stale comment about encrypted creds being AuthorizedUser only.
* refactor: centralize token cache filenames as constants
Extract hardcoded 'token_cache.json' and 'sa_token_cache.json' strings
into TOKEN_CACHE_FILENAME and SA_TOKEN_CACHE_FILENAME constants in
auth_commands.rs. Update all 5 reference sites in auth.rs and
auth_commands.rs to use the constants.
* refactor: unconditional key sync + parse_credential_file for all paths
- Unconditionally sync the .encryption_key file with the keyring value
on every successful read, not just when file is missing. Prevents
stale file backups causing decryption failures if keyring becomes
unavailable later. Uses save_key_file (overwrite + fsync) instead
of save_key_file_exclusive (create-only).
- Use parse_credential_file for plaintext credentials at default path,
consistent with encrypted/ADC paths. Adds ServiceAccount support
for credentials.json, not just AuthorizedUser.
- Updated test: keyring_backend_syncs_file_when_keyring_differs
verifies file content is overwritten to match keyring key.
* revert: remove scope-creep changes (constants + plaintext SA support)
Move TOKEN_CACHE_FILENAME/SA_TOKEN_CACHE_FILENAME constants and
parse_credential_file for the plaintext credentials path to a
follow-up refactor PR — keep this PR focused on stale credential
auto-recovery.
* style: use tokio::fs::write in async test functions
Replace std::fs::write with tokio::fs::write().await in async test
functions for consistency with the async runtime.
---------
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
* docs: warn about zsh \! history expansion in sheet range examples
Add a "Shell Tips" section to the gws-shared skill explaining that
zsh interprets \! inside single quotes as history expansion, which
mangles sheet ranges like 'Sheet1\!A1:B2'.
Replace single quotes with double quotes around sheet ranges
containing \! in:
- registry/recipes.yaml (source for generated recipe skills)
- src/helpers/sheets.rs (+read after_help examples)
- src/generate_skills.rs (shared skill template)
- Generated SKILL.md files for affected recipes and gws-sheets-read
Closes#268
* fix: escape double quotes in after_help string literal
The after_help string for +read uses a regular string literal, not a
raw string, so inner double quotes must be escaped with backslashes.
Add --html flag to +send, +reply, +reply-all, and +forward, enabling
HTML email composition. When set, --body is treated as HTML content and
the Content-Type switches from text/plain to text/html.
For replies and forwards, the quoted/forwarded block matches Gmail web
UI fidelity: gmail_quote_container class, gmail_sendername structure,
mailto links in attribution and metadata, <div dir="ltr"> wrapper on
quoted content, and RFC 2822 dates reformatted to Gmail's human-friendly
style. When the original message has no HTML body, plain text is
HTML-escaped with <br> line breaks as a fallback (with a stderr
diagnostic).
* feat(error): add structured exit codes for scriptable error handling
Replace the hardcoded `std::process::exit(1)` with a type-specific exit
code derived from the GwsError variant:
0 — success
1 — API error (GwsError::Api)
2 — auth error (GwsError::Auth)
3 — validation (GwsError::Validation)
4 — discovery (GwsError::Discovery)
5 — internal (GwsError::Other)
This allows shell scripts to branch on failure type without parsing the
JSON error output:
gws drive files list ...
case $? in
1) echo "API error — check your params" ;;
2) echo "Auth error — run: gws auth login" ;;
3) echo "Bad arguments" ;;
esac
Changes:
- Add GwsError::exit_code() mapping variants to codes
- Update main() to call std::process::exit(err.exit_code())
- Document exit codes in gws --help (print_usage)
- Document exit codes in README under new Exit Codes section
- Add 6 unit tests including a regression guard asserting all codes are distinct
* refactor(error): replace magic exit-code numbers with named constants
Add EXIT_CODE_API/AUTH/VALIDATION/DISCOVERY/OTHER associated constants
on GwsError so callers and tests reference symbolic names rather than
bare integers. Update exit_code() match arms and all tests accordingly.
The distinctness test now validates the constants array directly.
Addresses code-review feedback requesting named constants.
* refactor(error): centralize exit code help text via EXIT_CODE_DOCUMENTATION
Add a module-level EXIT_CODE_DOCUMENTATION constant — a static slice of
(code, description) pairs built from the EXIT_CODE_* constants. Replace
the hardcoded println! block in print_usage() with a loop over this slice
so the help output is always in sync with the defined constants and cannot
drift out of date.
Addresses code-review feedback requesting a single source of truth.
* fix: handle array-of-arrays in CSV formatter
The CSV formatter assumed all items were JSON objects when collecting
column names. APIs that return arrays of arrays (e.g. Sheets values)
produced empty newlines instead of data.
Add an early return path for non-object arrays that emits each inner
array's elements as CSV cells directly, mirroring the table formatter's
existing "array of non-objects" handling.
Fixes#283
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add coverage for flat scalar CSV formatting
---------
Co-authored-by: Dan Rassi <129646+drassi@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat(gmail): add --cc, --bcc, --to recipient management flags
Add --cc and --bcc to +send, --to and --bcc to +reply and +reply-all,
and --bcc to +forward. This brings all four Gmail helpers to feature
parity for basic recipient management.
Key behaviors:
- --to on reply/reply-all is additive (appends to auto-computed To)
- --remove only affects auto-computed recipients, not explicit flags
- Dedup with priority To > CC > BCC via new dedup_recipients()
- Validation deferred until after all additions and dedup
- Empty/whitespace --cc/--bcc/--to filtered to None at parse time
- BCC header included in raw message (Gmail API strips before delivery)
Also includes:
- CAUTION boxes in all four SKILL.md files
- SendConfig visibility narrowed to pub(super)
- Consistent "email address(es)" wording across clap help and SKILL.md
* refactor(gmail): extract shared MessageBuilder and fix pre-existing issues
Extract duplicated header-construction logic from send.rs, reply.rs, and
forward.rs into a shared MessageBuilder in mod.rs. This centralizes CRLF
header-injection sanitization and RFC 2047 subject encoding that were
previously inconsistent across the three paths.
Additional changes:
- Fix silent auth failure in send.rs (Err(_) swallowed all auth errors)
- Fix try_get_one error swallowing in parse_reply_args (explicit match
on MatchesError::UnknownArgument, propagate unexpected errors)
- Extract shared helpers: build_references, parse_optional_trimmed,
encode_header_value, sanitize_header_value
- Introduce ForwardEnvelope (analogous to ReplyEnvelope)
- Introduce ThreadingHeaders to group in_reply_to/references
* feat(credential_store): add GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND env var
Add gogcli-style backend selection for encryption key storage:
- keyring (default): OS keyring with file fallback
- file: .encryption_key file only (Docker/CI/headless)
Never delete .encryption_key — it always serves as a durable fallback
for environments where the keyring is ephemeral. When generating new
keys with backend=keyring, save to both keyring and file.
Extracts KeyringProvider trait + resolve_key() for testability.
25 tests covering both backends and all edge cases.
Fixes#344
* chore: regenerate skills [skip ci]
* fix(credential_store): use O_EXCL for race-safe key generation
Use create_new(true) (O_EXCL on Unix, CREATE_NEW on Windows) when
generating a new encryption key file. If another process wins the
race, read their key instead. Platform-independent.
* fix(credential_store): sync winner's key into keyring after file race
When two processes race to create the encryption key file, the loser
now syncs the winner's key back into the keyring. Without this, the
keyring and file could permanently diverge.
* test(credential_store): add 9 tests covering file exclusion, env parsing, and race paths
- save_key_file_exclusive: creates new file, rejects existing
- save_key_file: overwrites existing
- ensure_key_dir: creates nested dirs
- KeyringBackend: file/FILE/invalid parsing
- Race loser: syncs winner key to keyring
- Race loser: corrupt file gets overwritten
* feat(credential_store): security and robustness hardening
1. Warn on unrecognized KEYRING_BACKEND values instead of silent default
2. fsync after key file writes for crash durability
3. Zeroize decoded key material from heap after copy
4. Warn if key file has overly permissive Unix permissions (mode & 077)
5. Log which keyring backend was selected to stderr
6. Expose keyring_backend in 'gws auth status' JSON output
---------
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
- Pass original threadId to keep forward in sender's thread
- Add In-Reply-To and References headers for RFC 5322 compliance
- Add blank line between forwarded message metadata and body
- Remove spurious closing delimiter from forwarded message block
- Update SKILL.md to remove outdated threading tip
- Add test for References chain construction
Closes#88
* fix(auth): fall back to Discovery docs when `-s` specifies services not in static scope lists
When `gws auth login -s chat` (or any service not in the 7 static
scope lists) is used, the static filter returns no matching scopes.
Add a dynamic fallback that detects unmatched services and fetches
their OAuth scopes from the Google Discovery API. This leverages the
existing `fetch_discovery_document` with 24h caching.
Fixes#236
* refactor: optimize find_unmatched_services and parallelize Discovery fetches
Address review feedback:
- Avoid per-service HashSet allocation in find_unmatched_services by
collecting matched services first then computing the difference.
- Use futures_util::future::join_all to fetch Discovery docs in
parallel instead of sequentially.
* refactor: extract map_service_to_scope_prefix to deduplicate alias mapping
Share the service-name-to-scope-prefix mapping between
scope_matches_service and find_unmatched_services via a single helper.
* fix(auth): format extract_scopes_from_doc and deduplicate dynamic scopes
- Break long method chain in extract_scopes_from_doc to pass cargo fmt
- Deduplicate dynamic scopes in augment_with_dynamic_scopes to prevent
duplicate entries when dynamic results overlap with static ones
---------
Co-authored-by: Frank <qwer4488999@gmail.com>
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
The +triage helper uses the `q` query parameter when listing messages,
but Gmail's metadata scope does not support `q` and returns 403. When a
user's OAuth token includes both gmail.metadata and gmail.modify scopes,
the API may resolve to the metadata code path and reject the query.
Switch +triage from gmail.modify to gmail.readonly, which is the
minimum scope that supports query filtering and aligns with the
read-only nature of the triage command.
Fixes#265
Previously, get_or_create_key() unconditionally wrote the encryption key
to ~/.config/gws/.encryption_key on first run, even when the OS keyring
was available. This left the key material on disk as a plain file,
making credentials portable by copying the config directory.
Changes:
- Extract save_key_file() helper to deduplicate file-writing logic
- On keyring read success: delete stale .encryption_key (migration)
- On NoEntry + existing file: migrate key into keyring, then delete file
- On NoEntry + new key: try keyring first, only write file as fallback
Fixes#344
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
* feat(gmail): add +reply, +reply-all, and +forward helper commands
Add first-class reply and forward support to the Gmail helpers,
addressing the gap described in #88. These commands handle the
complex RFC 2822 threading mechanics (In-Reply-To, References,
threadId) that agents and CLI users struggle with today.
New commands:
- +reply: reply to a message with automatic threading
- +reply-all: reply to all recipients with --remove/--cc support
- +forward: forward a message with quoted original content
* fix(gmail): encode message_id in URL path and fix auth signature
- Use crate::validate::encode_path_segment() on message_id in
fetch_message_metadata URL construction per AGENTS.md rules
- Update auth::get_token calls to pass None for the new account
parameter added on main
* refactor(gmail): extract send_raw_email and deduplicate handlers
- Add send_raw_email() to mod.rs: shared encode→json→auth→execute
pattern for sending raw RFC 2822 messages via users.messages.send
- Simplify handle_reply: delegate send logic to send_raw_email
- Simplify handle_forward: delegate send logic to send_raw_email
Addresses code duplication feedback from PR review.
* fix(gmail): register --dry-run flag on reply/forward commands
The handlers read matches.get_flag("dry-run") but the flag was missing
from the clap command definitions, so it always returned false. Now
dry-run works for +reply, +reply-all, and +forward.
* chore: add changeset for gmail reply/forward feature
* style: apply cargo fmt formatting
* fix(gmail): register --dry-run flag on +send command
Same class of bug fixed for +reply/+reply-all/+forward — the handler
reads matches.get_flag("dry-run") but the arg was not registered.
* fix(gmail): honor Reply-To header and use exact address matching
- Prefer Reply-To over From when selecting reply recipients, fixing
incorrect routing for mailing lists and support systems
- Use exact email address comparison instead of substring matching
for --remove filtering and sender deduplication, preventing
unintended recipient removal (e.g. ann@ no longer drops joann@)
* test(gmail): add comprehensive coverage for reply address handling
- extract_email: malformed input (no closing bracket), empty string,
whitespace-only
- build_reply_all_recipients: display-name sender exclusion,
--remove with display name, extra --cc, CC becomes None when all
filtered, case-insensitive sender exclusion
* Improves reply-all recipient deduplication
Corrects how `build_reply_all_recipients` handles multi-address `Reply-To` headers.
Previously, only the first address from `Reply-To` was used for deduplication, leading to potential redundancy by including those addresses in the `Cc` field.
The updated logic now parses all addresses in `Reply-To`, ensuring they are fully moved to the `To` field and properly excluded from `Cc`.
* style(gmail): add missing Apache 2.0 copyright headers
reply.rs and forward.rs were missing the copyright header that all
other source files in the repo include.
* fix(gmail): use try_get_one for optional --remove arg in +reply
parse_reply_args used get_one("remove") which panics when called
from +reply (which does not register --remove). Switch to
try_get_one to safely return None for unregistered args.
* feat(gmail): support --dry-run without auth for reply/forward commands
Skip auth and message fetch when --dry-run is set by using placeholder
OriginalMessage data. This lets users preview the request structure
without needing credentials.
* fix(gmail): use RFC-aware mailbox list parsing for recipient splitting
Replace naive comma-split with split_mailbox_list that respects
quoted strings, so display names containing commas like
"Doe, John" <john@example.com> are handled correctly in reply-all
recipient parsing, deduplication, and --remove filtering.
* fix(gmail): handle escaped quotes in mailbox list splitting
split_mailbox_list toggled quote state on every `"` without accounting
for backslash-escaped quotes (`\"`), causing display names like
`"Doe \"JD, Sr\""` to split incorrectly at interior commas.
Track `prev_backslash` so `\"` inside quoted strings is treated as a
literal quote character rather than a delimiter toggle. Double
backslashes (`\\`) are handled correctly as well.
* fix(gmail): address PR review feedback for reply/forward helpers
- Use reqwest .query() for metadata params per AGENTS.md convention
- Add MIME-Version and Content-Type headers to raw messages
- Add --from flag to +reply, +reply-all, +forward for send-as/alias
- Narrow ReplyConfig/ForwardConfig visibility to pub(super)
- Refactor create_reply_raw_message args into ReplyEnvelope struct
* fix(gmail): address review feedback for reply/forward helpers
- Exclude authenticated user's own email from reply-all CC by
fetching user profile via Gmail API
- Use format=full to extract full plain-text body instead of
truncated snippet for quoting and forwarding
- Deduplicate CC addresses using a HashSet
- Reuse auth token from message fetch in send_raw_email to
eliminate double auth round-trip
- Propagate auth errors in send_raw_email instead of silently
falling back to unauthenticated requests
- Use consistent CRLF line endings in quoted and forwarded
message bodies per RFC 2822
* fix(gmail): Gmail reply and forward helpers
* fix(gmail): refactor shared reply-forward helpers
* Preserve repeated Gmail address headers
* chore: regenerate skills [skip ci]
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
* fix: respect account selection in MCP server and CLI --account flag (#221, #181)
MCP server now reads GOOGLE_WORKSPACE_CLI_ACCOUNT env var and passes it
to get_token instead of always using None (default account).
CLI filter_args_for_subcommand now dynamically locates the service name
instead of hardcoding skip(2), fixing --account before service name
causing unrecognized subcommand errors.
* fix: skip --api-version in first_arg detection
The first_arg loop only skipped --account but not --api-version,
so `gws --api-version v3 drive ...` would misidentify --api-version
as the service name. Now both global flags are consistently skipped.
* fix(mcp): conditionally include body/upload in full-mode tool schemas and drop empty body on execution
Full-mode tool schemas now only include `body` when the Discovery Document
method defines a request body, and `upload` when `supportsMediaUpload` is
true. This prevents LLMs from hallucinating these fields on GET-only methods.
Additionally, empty body objects (`{}`) are filtered out before execution
in both compact and full modes, and empty upload strings are ignored. LLMs
commonly send "body": {} on read-only methods, which causes Google APIs to
return HTTP 400.
* style: cargo fmt and add changeset for MCP tool schema fix
* fix(mcp): conditionally include page_all only for paginated methods
Only include the page_all property in full-mode tool schemas when the
method has a pageToken parameter, preventing LLMs from attempting
pagination on non-paginable methods.
* docs: update changeset to include page_all conditional change