* 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).
* docs: document helper commands and the + prefix convention
Add a "Helper Commands" section to the Advanced Usage chapter of the
README explaining:
- What the `+` prefix means and why it exists (visually distinct from
Discovery-generated method names, no collision risk)
- How to discover helpers via `gws <service> --help`
- A full reference table of all 23 helper commands across 11 services
- Usage examples for the most common helpers (gmail, sheets, calendar,
drive, workflow)
Fixes discoverability gap: users had no way to learn about helper
commands without reading the source code.
* fix(docs): correct +append and +upload examples per Gemini review
- gws sheets +append: flag is --spreadsheet (not --spreadsheet-id) and
+append has no --range argument
- gws drive +upload: file path is a positional argument, not --file flag
* docs: clarify script +push is destructive (replaces, not adds)
The +push helper replaces all files in an Apps Script project.
Update description to reflect this so users understand the action
is destructive before running it.
Addresses code-review feedback.
* chore(changeset): correct helper command count to 24 across 10 services
Addresses code-review feedback noting the count was off.
* 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
The idempotency guard only checked local tags, but CI checkouts
don't fetch tags. Add git ls-remote fallback so re-runs of the
Release workflow skip already-pushed tags.
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
* 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
Split x86_64-unknown-linux-gnu out of the build matrix into a dedicated
build-linux job. The smoketest now depends only on build-linux, running
as soon as that single build completes. The remaining cross-platform
builds (macOS, Windows, aarch64-linux) depend on the smoketest, so they
are skipped entirely if the smoketest fails — saving CI minutes.
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
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>
When actions were SHA-pinned in #341, the tool name was lost. The
original uses: taiki-e/install-action@cargo-llvm-cov passed the tool
name as the tag. After pinning to a SHA, the tool input must be
specified explicitly via with: tool: cargo-llvm-cov.
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Remove RUSTC_WRAPPER from global env and set it per-job only after the
sccache-action succeeds. When GitHub's cache API is unavailable, CI now
falls through to a plain (uncached) cargo build instead of failing.
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
* chore: Created local 'SECURITY.md' from remote 'SECURITY.md'
* chore: Created local '.vscode/extensions.json' from remote 'sync-files/defaults/.vscode/extensions.json'
* 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>
Switch reqwest from `rustls-tls` (bundled Mozilla roots via webpki-roots)
to `rustls-tls-native-roots` so the CLI trusts custom/corporate CA
certificates installed in the system trust store.
This fixes TLS handshake failures in enterprise environments that use
internal certificate authorities.