Commit Graph

257 Commits

Author SHA1 Message Date
googleworkspace-bot 7a8a3da2f0 chore: regenerate skills [skip ci] 2026-03-24 20:54:09 +00:00
Justin Poehnelt b4654d09fd fix: version sync workspace 2026-03-24 14:53:26 -06:00
googleworkspace-bot eacd436694 chore: release versions (#614)
Release / plan (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
v0.21.0
2026-03-24 14:44:26 -06:00
Justin Poehnelt 029e5def2b feat: extract google-workspace library crate (cargo workspace) (#613)
* feat: extract google-workspace library crate and restructure as cargo workspace

- Create crates/google-workspace/ with public modules: discovery, error, services, validate, client
- Move CLI binary to crates/cli/ (package: google-workspace-cli, binary: gws)
- Root Cargo.toml is now workspace-only
- Binary modules use thin re-exports from library (zero behavioral changes)
- Discovery fetch_discovery_document accepts cache_dir parameter for library consumers
- Break output.rs / error.rs circular dep by moving char detection to library validate module
- Update CI workflows for workspace (--workspace flags, path filters)
- Update dist-workspace.toml and policy.yml for new crate locations

Closes #386

* refactor: rename crates/cli to crates/google-workspace-cli

* chore: sync CLI version to 0.20.1 and regenerate skills

* chore: update labeler paths for workspace crate layout

* fix: update flake.nix to read version from CLI crate Cargo.toml

* fix: use tokio::fs for non-blocking I/O in library discovery cache

* docs: update AGENTS.md source layout for workspace structure

* docs: add crate-level READMEs for crates.io

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-24 14:40:03 -06:00
googleworkspace-bot 3ac16ee595 chore: release versions (#606)
* chore: release versions

* chore: regenerate skills [skip ci]

---------

Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-24 13:28:51 -06:00
Justin Poehnelt 2ddb46e39f test(gmail): add regression tests for RFC 2822 display name quoting (#610)
The original bug from #513 (display names with commas/parens causing
'Invalid To header') was already fixed by the mail-builder migration
in #482. This adds explicit regression tests to prevent regressions.

Closes #513

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-24 13:25:54 -06:00
Justin Poehnelt b8fd3d966f fix(client): add 10s connect timeout to prevent initial hangs (#608)
* fix(client): add 10s connect timeout to prevent initial hangs

* fix(client): retry on transient connect/timeout errors

* chore: regenerate skills [skip ci]

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-24 12:49:23 -06:00
googleworkspace-bot 75a7121713 chore: sync skills with Discovery API (#607)
* chore: regenerate skills from Discovery API

* chore: regenerate skills [skip ci]

---------

Co-authored-by: jpoehnelt <3392975+jpoehnelt@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-24 12:40:27 -06:00
Yannik Tausch 2bfcca97ca feat: move version from top-level SKILL.md frontmatter to metadata and track CLI version (#601) 2026-03-24 11:59:55 -06:00
Justin Poehnelt 203acc7bcf fix(ci): skip publish-skills on fork PRs where secrets are unavailable (#605)
Release / plan (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
v0.20.0
2026-03-24 11:59:37 -06:00
googleworkspace-bot 3e1e0e11f3 chore: release versions (#603)
* chore: release versions

* chore: regenerate skills [skip ci]

---------

Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-24 11:50:23 -06:00
Justin Poehnelt 4663021b4a fix(ci): skip publish-skills on fork PRs (#604)
* fix(ci): skip publish-skills on fork PRs where secrets are unavailable

* chore: regenerate skills [skip ci]

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-24 11:50:06 -06:00
Malo Bourgon e782dd70b5 feat(gmail): forward original attachments and preserve inline images (#589)
Include original message attachments on +forward by default, matching
Gmail web behavior. Add --no-original-attachments flag to opt out
(skips file attachments but preserves inline images in HTML mode).

Preserve cid: inline images in HTML mode for both +forward and
+reply/+reply-all by building the correct multipart/related MIME
structure via mail-builder's MimePart API. Gmail's API rewrites
Content-Disposition: inline to attachment in multipart/mixed, so
explicit multipart/related is required.

In plain-text mode, inline images are not included for both forward
and reply, matching Gmail web behavior.

Key implementation details:
- Single-pass MIME payload walker replaces separate text/html extractors
- OriginalPart metadata type with lazy attachment data fetching
- Part classification uses Content-Disposition to distinguish regular
  attachments from inline images (some clients set Content-ID on both)
- Content-ID and content_type sanitized against CRLF header injection
- Size preflight before downloading original attachments
- Remote filename sanitization (not rejection) for sender-controlled names
- Walker does not recurse into hydratable parts (e.g., message/rfc822)
2026-03-24 11:32:22 -06:00
Justin Poehnelt 477f5d90db docs: add helper command guidelines and anti-patterns (#598)
Release / plan (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
* docs: add helper command guidelines and anti-patterns

* Apply suggestions from code review

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
v0.19.0
2026-03-23 15:27:03 -06:00
googleworkspace-bot 834fa7d25a chore: release versions (#590)
* chore: release versions

* chore: regenerate skills [skip ci]

---------

Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-23 14:49:28 -06:00
github-actions[bot] e82c02c5a2 style: cargo fmt 2026-03-23 20:38:28 +00:00
JoeVenner 8a749c2c11 feat: add --dry-run support to events helper commands (#457)
Add dry-run mode to gws events +renew and gws events +subscribe commands.
When --dry-run is specified, the commands print what actions would be
taken without making any API calls. This allows agents to simulate
requests and learn without reaching the server.
2026-03-23 14:38:17 -06:00
Adem Akdoğan f157208449 fix: use block-style YAML sequences in SKILL.md frontmatter (#580)
Replace flow sequences (bins: ["gws"], skills: [...]) with block-style
sequences in all generated SKILL.md frontmatter templates.

Flow sequences are valid YAML but rejected by strictyaml, which the
Agent Skills reference implementation (agentskills validate) uses to
parse frontmatter. This caused all 93 generated skills to fail
validation.

Also adds unit tests verifying that service, shared, persona, and
recipe skill templates produce block-style sequences only.

Fixes #521
2026-03-23 14:31:57 -06:00
Justin Poehnelt a078945f2a refactor: replace manual arg parsing in auth commands with clap (#594)
* refactor: replace manual arg parsing in auth commands with clap

- Add ScopeMode enum (Default, Readonly, Full, Custom) for type-safe
  scope selection
- Build auth_command() with clap subcommands: login, setup, status,
  export, logout
- Replace manual --help check and match dispatch in handle_auth_command
  with clap subcommand routing
- Replace two-pass manual parsing in handle_login (services extraction)
  and resolve_scopes (scopes/readonly/full flags) with single clap parse
- Add --unmasked flag to export subcommand via clap
- Preserve run_login(&[]) public API for setup.rs compatibility
- Update all 63 tests to use ScopeMode-based API

* refactor: extract parse_login_args helper to deduplicate scope/services parsing

* fix: make --readonly, --full, --scopes mutually exclusive via clap conflicts

* fix: filter empty strings from custom scopes parsing

* refactor: extract build_login_subcommand to eliminate fragile .expect() lookup

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-23 14:25:42 -06:00
Justin Poehnelt d341de29a2 fix(setup): handle --help/-h before launching setup wizard (#592)
* fix(setup): handle --help/-h before launching setup wizard

Check for --help/-h at the top of run_setup() before any work that
requires gcloud. Previously, running 'gws auth setup --help' would
launch the full setup flow.

Fixes #280

* refactor: extract SETUP_USAGE into const per review

* refactor: replace manual setup arg parsing with clap

Use clap::Command for gws auth setup argument parsing instead of manual
while-loop parser. This gives us:
- Automatic --help/-h handling (no manual check needed)
- Proper error messages for unknown flags
- Consistent with other clap usage in the codebase
- Help text stays in sync with actual arguments

* fix: distinguish --help (clean exit) from invalid flags (error)

Change parse_setup_args return type from Result<SetupOptions, ()> to
Result<Option<SetupOptions>, GwsError> so that:
- Ok(Some(opts)) = successful parse
- Ok(None) = --help/--version displayed (clean exit, code 0)
- Err(GwsError) = invalid flags (error exit, non-zero code)

* fix: propagate e.print() IO errors instead of silently ignoring

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-23 13:08:28 -06:00
Justin Poehnelt d6794019f8 fix(auth): prevent mask_secret panic on multi-byte UTF-8 secrets (#591)
Use char-based indexing instead of byte-offset slicing in mask_secret()
to prevent panics when secrets contain multi-byte UTF-8 characters
(accented letters, emoji, CJK, etc.).

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-23 12:00:16 -06:00
Justin Poehnelt 01fb4f297c chore: fix package name in changeset 2026-03-23 11:25:35 -06:00
Sidharth Rajmohan b4d5e26424 fix(auth): propagate error when token directory cannot be created (#542)
* fix(auth): propagate errors when token directory creation/permissions fail

Previously, failures to create the token directory or set its permissions
were silently ignored using 'let _ = ...'. This could lead to confusing
errors later or security issues if permissions were left insecure.

Now properly propagates errors from:
- tokio::fs::create_dir_all()
- std::fs::set_permissions() (on Unix)

Also sanitizes the path in error messages to prevent terminal escape
sequence injection, aligned with codebase security practices.

* fix(auth): use spawn_blocking for set_permissions to avoid blocking async runtime

Following the reviewer suggestion, std::fs::set_permissions is now executed
via tokio::task::spawn_blocking to avoid potentially blocking the async
runtime thread, which can cause performance issues or deadlocks under load.

* fix(auth): use tokio::fs::set_permissions instead of spawn_blocking

Simplifies the code by using Tokio's native async set_permissions,
removing the need for manual thread spawning.
2026-03-23 11:25:03 -06:00
googleworkspace-bot e9970db26f chore: release versions (#553)
Release / plan (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
v0.18.1
2026-03-18 14:56:16 -06:00
Justin Poehnelt d65578190e ci: Add CI validation for changeset package names and update style guide with new sections and AGENTS.md reference. (#552) 2026-03-18 14:42:44 -06:00
Justin Poehnelt a87037b337 feat: handle SIGTERM in +watch and +subscribe for clean shutdown (#550)
* feat: handle SIGTERM in +watch and +subscribe for clean shutdown

Add shared shutdown_signal() helper that merges SIGINT and SIGTERM
into a single future. Replace tokio::signal::ctrl_c() in both watch
and subscribe pull loops so they exit cleanly under Kubernetes,
Docker, and systemd.

On non-Unix platforms, only SIGINT (Ctrl+C) is handled.

* fix: register SIGTERM handler once via persistent background task

Use OnceLock + tokio::sync::Notify so the signal handler stays active
for the process lifetime. Eliminates the race window between loop
iterations where a SIGTERM would bypass the handler.

* fix: graceful fallback when SIGTERM registration fails

Replace expect() with match: if signal(SIGTERM) fails, log a warning
and fall back to SIGINT-only. Prevents silent task death that would
hang all shutdown_signal() callers indefinitely.

* fix: prevent spurious shutdown from ignored ctrl_c errors

Use Ok(_) pattern matching in select! branches and expect() for
standalone ctrl_c().await calls. Previously .ok() silently swallowed
errors, causing notify_waiters() to fire immediately.

* fix: handle ctrl_c error in select! to avoid losing SIGINT branch

Bind the full Result from ctrl_c() and expect() on it instead of
pattern matching Ok(_), which silently dropped the branch on Err.

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-18 14:42:11 -06:00
googleworkspace-bot 23ffc33cf2 chore: release versions (#547)
Release / plan (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
v0.18.0
2026-03-18 09:48:41 -06:00
Malo Bourgon 908cf7356f feat(gmail): auto-populate From display name + fix reply-all to own message (#530)
* feat(gmail): auto-populate From with display name from send-as settings

Fetch the user's send-as identities via /users/me/settings/sendAs to set
the From header with a display name in all mail helpers, matching Gmail
web client behavior. The gmail.modify scope already covers this endpoint.

Three resolution cases:
- No --from: use the default send-as identity (display name + email)
- --from with bare email: enrich from send-as list if the alias exists
- --from with display name: use as-is, skip the API call

For Workspace accounts where the primary address inherits its display
name from the organization directory (sendAs returns empty displayName),
falls back to the People API to fetch the profile name. This requires
the userinfo.profile scope, which is now included in the identity scopes
that auth login always requests. Degrades gracefully with a tip if the
scope hasn't been granted yet.

Introduces build_api_error, a shared helper that parses Google API JSON
error responses (extracting message, reason, and enable URL), matching
the executor's handle_error_response pattern. Used by all four Gmail API
functions. All error messages printed to stderr are sanitized via
sanitize_for_terminal.

In +send, auth uses the discovery doc scopes rather than hardcoding
gmail.modify, preserving compatibility with narrower send-only OAuth
setups. In reply-all, the profile endpoint is always called for
self-email dedup since the primary address may differ from the send-as
alias.

* fix(gmail): handle reply-all to own message correctly

When replying-all to a message you sent, the original sender (you) was
excluded from To, leaving it empty and producing an error. Gmail web
handles this by using the original To recipients as reply targets.

Detect self-reply by checking if the original From matches the user's
primary email or send-as alias, then swap the candidate logic:
- Self-reply: To = original To, CC = original CC
- Normal reply: To = Reply-To or From, CC = original To + CC
2026-03-18 09:47:18 -06:00
Malo Bourgon 1e90380de3 fix(gmail): remove duplicate attachment arg on +send (#532)
The +send subcommand defined its own "attachment" arg in addition to the
"attach" arg already provided by common_mail_args. Since parse_attachments
reads "attach", the duplicate "attachment" arg was dead — +send --attachment
was silently accepted by clap but the value was never read.
2026-03-18 09:45:42 -06:00
Justin Poehnelt 2e909ae905 refactor: consolidate output hygiene into output.rs (#527)
* refactor: consolidate output hygiene into output.rs

Introduce src/output.rs that consolidates:
- sanitize_for_terminal (upgraded: now also strips bidi overrides,
  zero-width chars, directional isolates, line/paragraph separators)
- reject_dangerous_chars (renamed from reject_control_chars)
- is_dangerous_unicode predicate
- colorize + stderr_supports_color (NO_COLOR + TTY detection)
- status/warn/info stderr helpers (auto-sanitize)

Migrate existing callers via re-exports from error.rs and validate.rs.

Fix watch.rs:
- Sanitize raw API error body in eprintln (was high-risk injection vector)
- Replace 3 inline ANSI escape codes with colorize() for NO_COLOR support

Fix triage.rs:
- Sanitize user --query string in no_messages_msg output

* refactor: remove re-export indirection, import directly from output

Update all 10 caller files to import sanitize_for_terminal directly
from crate::output instead of going through crate::error re-exports.
Remove pub(crate) re-exports from error.rs and validate.rs.

* fix: use char::is_control() in reject_dangerous_chars for C1 coverage

Address PR review: the manual (c as u32) < 0x20 check missed
C1 control characters (U+0080-U+009F), including CSI (U+009B) which
can inject terminal escape sequences. Using char::is_control() covers
both C0 and C1 ranges.

Add test for CSI rejection.

* fix: validate ansi_color in colorize() to prevent injection

Defense-in-depth: only emit ANSI escape codes when ansi_color
contains exclusively ASCII digits. Falls back to plain text if
an invalid color code is passed.

---------

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-18 09:34:20 -06:00
Justin Poehnelt 6e4daaf4f9 feat(gmail): Gmail helpers rollup — mail-builder, --attachment, +read (#526)
* refactor(gmail): replace hand-rolled email construction with mail-builder

Replace custom MessageBuilder, RFC 2047 encoding, header sanitization,
and address encoding (including #482) with the mail-builder crate
(Stalwart Labs, 0 runtime deps). Each command builds a
mail_builder::MessageBuilder directly.

Introduce structured types throughout:
- Mailbox type (parsed display name + email) replaces raw string passing
- sanitize_control_chars strips ASCII control characters (CRLF, null,
  tab, etc.) at the parse boundary — defense-in-depth for mail-builder's
  structured header types, superseding sanitize_header_value,
  sanitize_component, and encode_address_header from #482
- OriginalMessage fields use Option<T> instead of empty-string sentinels
- parse_original_message returns Result with validation (threadId, From,
  Message-ID)
- Pre-parsed Config types (SendConfig, ForwardConfig, ReplyConfig) with
  Vec<Mailbox> — parse at the boundary, not downstream
- parse_forward_args and parse_send_args return Result with --to
  validation, consistent with parse_reply_args
- parse_optional_mailboxes helper normalizes Some(vec![]) to None for
  optional address fields (--cc, --bcc, --from)
- Envelope types borrow from Config + OriginalMessage with lifetimes
- Message IDs stored bare (no angle brackets), parsed once at boundary
- References stored as Vec<String> instead of space-separated string
- ThreadingHeaders bundles In-Reply-To + References with debug_assert
  for bare-ID convention
- Shared CLI arg builders (common_mail_args, common_reply_args)
  eliminate duplicated --cc/--bcc/--html/--dry-run definitions

Additional improvements:
- finalize_message returns Result instead of panicking via .expect()
- Mailbox::parse_list filters empty-email entries (trailing comma edge
  case)
- format_email_link percent-encodes mailto hrefs to prevent parameter
  injection
- Forward date handling: omits Date line when absent instead of showing
  empty "Date: "
- Dry-run auth: log skipped auth as diagnostic instead of silently
  discarding errors
- Restore --html tips in after_help strings (gmail_quote CSS, cid:
  image warnings, HTML fragment advice) lost in release PR #434
- Update execute_method call for upload_content_type parameter (#429)

Delete: MessageBuilder, encode_header_value, sanitize_header_value,
encode_address_header, sanitize_component, extract_email,
extract_display_name, split_mailbox_list, build_references.

* feat(gmail): add --from flag to +send for send-as alias support

Consistent with +reply, +reply-all, and +forward which already support
--from. Uses the same parse_optional_mailboxes path and
apply_optional_headers plumbing.

* fix: quote display names with RFC 2822 special characters in +reply

When replying to emails from corporate senders with display names like
"Anderson, Rich (CORP)" <email@adp.com>, the +reply command fails with
"Invalid To header" (400) from the Gmail API.

The root cause: encode_address_header() strips quotes from the display
name via extract_display_name(), then reconstructs the address without
re-quoting. When the display name contains RFC 2822 special characters
(commas, parentheses), the unquoted form is ambiguous — commas split
it into multiple malformed mailboxes and parentheses are interpreted
as RFC 2822 comments.

Fix: re-quote the display name when it contains any RFC 2822 special
characters, using a single-pass character iterator that preserves
already-escaped sequences and escapes bare quotes/backslashes.

Fixes #512

* feat(gmail): add --attachment flag, +read helper, and mail-builder migration

Consolidates PRs #491, #513, #517, and #502 into a single rollup:

- Migrate message construction to mail-builder crate (RFC-compliant MIME)
- Add --from flag to +send for send-as alias support
- Add --attachment flag to +send with MIME auto-detection and path validation
- Add +read helper for extracting message body/headers (text, HTML, JSON)
- Serialize support for OriginalMessage and Mailbox types
- Display name quoting handled natively by mail-builder

* chore: regenerate skills [skip ci]

* fix: use validate_safe_file_path for attachment path validation

Addresses Gemini review: validate_safe_dir_path hardcodes '--dir' in
error messages. validate_safe_file_path accepts the flag name, so errors
now correctly reference '--attachment'.

* refactor: make OriginalMessage.thread_id optional

The Gmail API does not guarantee threadId on all message resources
(e.g. drafts). Making it Option<String> prevents parse failures on
valid messages and avoids requiring thread_id in helpers like +read
that don't use it.

* fix: use canonicalized path for attachment file operations (TOCTOU)

validate_safe_file_path returns a canonicalized PathBuf. Use it for
exists/is_file checks and downstream file reads instead of the original
un-resolved path to prevent time-of-check/time-of-use races.

* feat(gmail): add --attach flag for file attachments

Add -a/--attach to +send, +reply, +reply-all, and +forward. Can be
specified multiple times for multiple attachments. MIME type is auto-
detected via mime_guess2. Closes #247.

Send via the Gmail API upload endpoint (multipart/related with
message/rfc822 media type) instead of base64-encoding into a JSON raw
field. This raises the size limit from ~5MB (metadata-only endpoint) to
35MB (upload endpoint, per discovery document).

Introduce UploadSource enum in the executor to consolidate upload_path,
upload_content_type, and upload_bytes into a single type-safe parameter.
File and Bytes variants make the two upload strategies (from disk vs.
from memory) mutually exclusive by construction.

Validates attachment paths (control characters, regular file, non-empty)
and total size (25MB raw limit, accounting for base64 expansion of
attachments within the MIME message against the 35MB API limit). Size
check uses actual bytes read to avoid TOCTOU race.

* chore: update changeset and fix integration with malob's attachment impl

Update changeset to reflect combined work. Fix thread_id type mismatches
in new tests from cherry-pick. Fix upload_path scope in main.rs. Make
reject_control_chars pub(crate) for attachment validation.

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>

* chore: regenerate skills [skip ci]

* fix: restore MIME sanitization and terminal escape protection in executor

Restore two security features accidentally lost during the UploadSource
refactor:

1. resolve_upload_mime: restructure from early-returns to collect-then-
   sanitize pattern — strips control chars from user-supplied MIME types
   to prevent CRLF header injection.

2. Model Armor error path: restore sanitize_for_terminal on error messages
   to prevent terminal escape sequence injection from API responses.

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>

* chore: remove duplicate changeset from cherry-pick

gmail-attach-flag.md duplicated content already in gmail-helpers-rollup.md.
Both were marked minor, which would cause a double version bump.

* fix: add path traversal protection to attachment validation

Replace reject_control_chars with validate_safe_file_path in
parse_attachments. All file operations (metadata, read, filename
extraction, MIME detection) now use the canonicalized path, preventing
path traversal attacks (e.g. ../../.ssh/id_rsa) and closing TOCTOU gaps.

Update tests to use CWD-relative temp directories (tempdir_in("."))
since validate_safe_file_path rejects paths outside the working directory.

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>

* refactor: deduplicate terminal sanitizer in read.rs

Replace the local sanitize_terminal_output function with the existing
crate::error::sanitize_for_terminal via import alias. This eliminates
code duplication and provides consistent sanitization across the codebase.

The crate-wide sanitizer also correctly strips CR (carriage return) which
can be abused for terminal overwrite attacks.

---------

Co-authored-by: Malo Bourgon <mbourgon@gmail.com>
Co-authored-by: Rich Anderson <richanderson00@gmail.com>
Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-17 16:55:50 -06:00
googleworkspace-bot 9c26e3c4a9 chore: release versions (#522)
Release / plan (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
v0.17.0
2026-03-17 14:27:07 -06:00
Justin Poehnelt 6f92e5b5f6 fix: stderr/output hygiene rollup (#525)
* 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>
2026-03-17 14:15:11 -06:00
Justin Poehnelt b241a5be92 fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths (#523)
* 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>
2026-03-17 13:18:06 -06:00
googleworkspace-bot 398e80ce5b chore: sync skills with Discovery API (#524)
* chore: regenerate skills from Discovery API

* chore: regenerate skills [skip ci]

---------

Co-authored-by: jpoehnelt <3392975+jpoehnelt@users.noreply.github.com>
Co-authored-by: googleworkspace-bot <googleworkspace-bot@users.noreply.github.com>
2026-03-17 12:25:41 -06:00
github-actions[bot] 157257035b style: cargo fmt 2026-03-17 18:07:08 +00:00
Abhi Ram Reddy Salammagari 8458104c83 fix(validate): reject dangerous Unicode characters in input validation (#484)
* 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.
2026-03-17 12:06:44 -06:00
github-actions[bot] 186ee3051e style: cargo fmt 2026-03-17 18:05:16 +00:00
Sidharth Rajmohan 1b0a21fa13 feat: support google meet video conferencing in calendar +insert (#506)
* 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
2026-03-17 12:05:00 -06:00
Dicky Arinal 58fd38e6ab docs: fix Environment Variables table formatting in README (#490)
* docs: fix Environment Variables table formatting in README

Remove blank line that was breaking the markdown table into two separate tables.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* chore: trigger CLA recheck

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-17 11:51:16 -06:00
Sidharth Rajmohan 811fe7baeb fix(security): eliminate TOCTOU race condition in atomic writes (#500)
- 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
2026-03-17 11:45:18 -06:00
googleworkspace-bot 1308786bdb chore: release versions (#481)
Release / plan (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
v0.16.0
2026-03-13 17:28:18 -06:00
Justin Poehnelt c61b9cbd40 fix(gmail): RFC 2047 encode non-ASCII display names in address headers (#482)
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>
2026-03-13 17:21:41 -06:00
Justin Poehnelt 47afe5fdb3 feat(timezone): use Google account timezone for day-boundary calculations (#480)
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>
2026-03-13 16:41:16 -06:00
googleworkspace-bot e4a59c1023 chore: release versions (#479)
Release / plan (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
v0.15.0
2026-03-13 15:29:02 -06:00
Justin Poehnelt 6f3e0906d3 feat: add opt-in structured HTTP request logging via tracing (#478)
Add PII-free structured logging controlled by two environment variables:
- GOOGLE_WORKSPACE_CLI_LOG: stderr log filter (e.g., gws=debug)
- GOOGLE_WORKSPACE_CLI_LOG_FILE: directory for JSON log files (daily rotation)

Logging is silent by default (zero overhead). Instrumented sites:
- executor.rs: API request/response (method, status, latency)
- client.rs: 429 retry events
- discovery.rs: cache hit vs network fetch

Co-authored-by: jpoehnelt-bot <jpoehnelt-bot@users.noreply.github.com>
2026-03-13 15:27:38 -06:00
googleworkspace-bot f8bcb35aa7 chore: release versions (#475)
Release / plan (push) Has been cancelled
Release / build-global-artifacts (push) Has been cancelled
Release / build-local-artifacts (${{ join(matrix.targets, ', ') }}) (push) Has been cancelled
Release / host (push) Has been cancelled
Release / publish-npm (push) Has been cancelled
Release / announce (push) Has been cancelled
v0.14.0
2026-03-13 15:02:00 -06:00
Justin Poehnelt 945ac91604 fix: stream multipart uploads to avoid OOM on large files (#477)
* 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>
2026-03-13 14:56:33 -06:00
github-actions[bot] 835e1f16c0 style: cargo fmt 2026-03-13 20:18:49 +00:00
Huglo dc561e0ffe feat: add --upload-content-type flag and smart MIME inference for uploads (#429)
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
2026-03-13 14:18:30 -06:00