diff --git a/.changeset/sheets-append-range.md b/.changeset/sheets-append-range.md new file mode 100644 index 00000000..1f6ec7b2 --- /dev/null +++ b/.changeset/sheets-append-range.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": minor +--- + +Add `--range` flag to `sheets +append` for targeting specific sheet tabs diff --git a/.gemini/style_guide.md b/.gemini/style_guide.md index cecbca88..5352149c 100644 --- a/.gemini/style_guide.md +++ b/.gemini/style_guide.md @@ -4,6 +4,8 @@ `gws` is a Rust CLI that dynamically generates commands from Google Discovery Documents at runtime. It does NOT use generated Rust crates (`google-drive3`, etc.) for API interaction. Do not suggest adding API-specific crates to `Cargo.toml`. +For additional context, read `AGENTS.md`. + ## Security: Trusted vs Untrusted Inputs This CLI is frequently invoked by AI/LLM agents. CLI arguments may be adversarial. @@ -17,7 +19,7 @@ The `codecov/patch` check requires new/modified lines to be covered by tests. Pr ## Changesets -Every PR must include a `.changeset/.md` file. Use `patch` for fixes/chores, `minor` for features, `major` for breaking changes. +Every PR must include a `.changeset/.md` file. The package name **must** be `"@googleworkspace/cli"` (not `"googleworkspace-cli"`). Use `patch` for fixes/chores, `minor` for features, `major` for breaking changes. ## PR Scope @@ -28,8 +30,10 @@ Examples of scope creep to avoid: - Adding constants for strings used elsewhere is a separate cleanup task. - Making a pre-existing function atomic is an enhancement, not a fix for the current PR. -## Code Style +## Severity Calibration + +Mark issues as **critical** only when they cause data loss, security vulnerabilities, or incorrect behavior under normal conditions. Theoretical failures in infallible system APIs (e.g., `tokio::signal::ctrl_c()` registration) are **low** severity — do not label them critical. Contradicting a prior review suggestion (e.g., suggesting `expect()` then flagging `expect()` as wrong) erodes trust; verify consistency with earlier comments before posting. + +## Helper Commands (`+verb`) -- Rust: `cargo clippy -- -D warnings` must pass. `cargo fmt` enforced via pre-commit hook. -- Node.js: Use `pnpm` not `npm`. -- OAuth scope strings in test code will trigger "restricted/sensitive scope" warnings — these are expected and should be ignored. +Helpers are handwritten commands that provide value Discovery-based commands cannot: multi-step orchestration, format translation, or multi-API composition. **Do not accept helpers that wrap a single API call, add flags to expose data already in the API response, or re-implement Discovery parameters as custom flags.** See [`src/helpers/README.md`](../src/helpers/README.md) for full guidelines and anti-patterns. diff --git a/.github/labeler.yml b/.github/labeler.yml index f4507729..c3fb0715 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -4,40 +4,43 @@ "area: auth": - changed-files: - any-glob-to-any-file: - - src/auth.rs - - src/auth_commands.rs - - src/setup.rs - - src/accounts.rs - - src/credential_store.rs - - src/token_storage.rs - - src/oauth_config.rs + - crates/google-workspace-cli/src/auth.rs + - crates/google-workspace-cli/src/auth_commands.rs + - crates/google-workspace-cli/src/setup.rs + - crates/google-workspace-cli/src/accounts.rs + - crates/google-workspace-cli/src/credential_store.rs + - crates/google-workspace-cli/src/token_storage.rs + - crates/google-workspace-cli/src/oauth_config.rs "area: discovery": - changed-files: - any-glob-to-any-file: - - src/discovery.rs - - src/services.rs + - crates/google-workspace-cli/src/discovery.rs + - crates/google-workspace-cli/src/services.rs + - crates/google-workspace/src/discovery.rs + - crates/google-workspace/src/services.rs "area: http": - changed-files: - any-glob-to-any-file: - - src/executor.rs - - src/client.rs + - crates/google-workspace-cli/src/executor.rs + - crates/google-workspace-cli/src/client.rs + - crates/google-workspace/src/client.rs "area: tui": - changed-files: - any-glob-to-any-file: - - src/setup_tui.rs + - crates/google-workspace-cli/src/setup_tui.rs "area: mcp": - changed-files: - any-glob-to-any-file: - - src/mcp_server.rs + - crates/google-workspace-cli/src/mcp_server.rs "area: skills": - changed-files: - any-glob-to-any-file: - - src/generate_skills.rs + - crates/google-workspace-cli/src/generate_skills.rs - skills/** "area: docs": @@ -53,16 +56,23 @@ - .github/workflows/release-changesets.yml - dist-workspace.toml - Cargo.toml + - crates/*/Cargo.toml "area: core": - changed-files: - any-glob-to-any-file: - - src/main.rs - - src/commands.rs - - src/error.rs - - src/formatter.rs - - src/fs_util.rs - - src/helpers/** - - src/text.rs - - src/validate.rs - - src/schema.rs + - crates/google-workspace-cli/src/main.rs + - crates/google-workspace-cli/src/commands.rs + - crates/google-workspace-cli/src/error.rs + - crates/google-workspace-cli/src/formatter.rs + - crates/google-workspace-cli/src/fs_util.rs + - crates/google-workspace-cli/src/helpers/** + - crates/google-workspace-cli/src/text.rs + - crates/google-workspace-cli/src/validate.rs + - crates/google-workspace-cli/src/schema.rs + +"crate: google-workspace": + - changed-files: + - any-glob-to-any-file: + - crates/google-workspace/src/** + - crates/google-workspace/Cargo.toml diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 00000000..571cabb8 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,45 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Audit + +on: + push: + branches: [main] + paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml'] + pull_request: + branches: [main] + paths: ['Cargo.lock', 'Cargo.toml', 'crates/*/Cargo.toml'] + schedule: + - cron: '0 6 * * *' # Daily at 06:00 UTC + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install cargo-audit + uses: taiki-e/install-action@a37010ded18ff788be4440302bd6830b1ae50d8b # cargo-llvm-cov + with: + tool: cargo-audit + + - name: Run cargo audit + run: cargo audit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee9603fc..1eedc997 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,7 @@ jobs: outputs: rust: ${{ steps.filter.outputs.rust }} nix: ${{ steps.filter.outputs.nix }} + skills: ${{ steps.filter.outputs.skills }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3 @@ -45,12 +46,15 @@ jobs: rust: - '**/*.rs' - 'Cargo.toml' + - 'crates/*/Cargo.toml' - 'Cargo.lock' - 'build.rs' - '.cargo/**' nix: - 'flake.nix' - 'flake.lock' + skills: + - 'skills/**' test: name: Test @@ -87,7 +91,7 @@ jobs: key: test-${{ matrix.os }} - name: Run tests - run: cargo test --verbose + run: cargo test --workspace --verbose nix: name: Nix @@ -146,7 +150,20 @@ jobs: fi - name: Clippy - run: cargo clippy -- -D warnings + run: cargo clippy --workspace -- -D warnings + + deny: + name: Cargo Deny + needs: changes + if: needs.changes.outputs.rust == 'true' || github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Cargo deny + uses: EmbarkStudios/cargo-deny-action@3fd3802e88374d3fe9159b834c7714ec57d6c979 # v2.0.15 + with: + command: check skills: @@ -189,6 +206,30 @@ jobs: echo "::warning::Skills are out of date — the hourly auto-sync PR will fix this automatically." fi + lint-skills: + name: Lint Skills + needs: changes + if: needs.changes.outputs.skills == 'true' || github.event_name == 'push' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + + - name: Validate skills + run: | + failed=0 + for skill_dir in skills/*/; do + if ! uvx --from skills-ref@0.1.1 agentskills validate "$skill_dir"; then + failed=1 + fi + done + if [ "$failed" -ne 0 ]; then + echo "::error::One or more skills failed validation." + exit 1 + fi + build-linux: name: Build (Linux x86_64) needs: changes diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml index 6b0d6568..271f711d 100644 --- a/.github/workflows/policy.yml +++ b/.github/workflows/policy.yml @@ -34,8 +34,10 @@ jobs: fetch-depth: 0 - name: Enforce AGENTS.md rules run: | - if grep -qE "^google-[a-zA-Z0-9_-]+[[:space:]]*=" Cargo.toml; then - echo "::error file=Cargo.toml::Violates AGENTS.md: Adding generated google-* crates is prohibited. The CLI uses dynamic schema discovery at runtime." + # Check CLI crate for prohibited google-* registry crates. + # Path dependencies (e.g. google-workspace = { path = ... }) are allowed. + if grep -E "^google-[a-zA-Z0-9_-]+[[:space:]]*=" crates/google-workspace-cli/Cargo.toml | grep -v 'path[[:space:]]*='; then + echo "::error file=crates/google-workspace-cli/Cargo.toml::Violates AGENTS.md: Adding generated google-* crates is prohibited. The CLI uses dynamic schema discovery at runtime." exit 1 fi echo "Policy check passed." @@ -53,3 +55,13 @@ jobs: exit 1 fi echo "Changeset file found!" + - name: Validate Changeset Package Name + if: github.event_name == 'pull_request' + run: | + for f in $(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^.changeset/.*\.md$"); do + if grep -q '"googleworkspace-cli"' "$f"; then + echo "::error file=$f::Wrong package name. Use '\"@googleworkspace/cli\"' not '\"googleworkspace-cli\"'." + exit 1 + fi + done + echo "Changeset package names valid!" diff --git a/.github/workflows/publish-skills.yml b/.github/workflows/publish-skills.yml index a6719832..35214380 100644 --- a/.github/workflows/publish-skills.yml +++ b/.github/workflows/publish-skills.yml @@ -21,6 +21,8 @@ concurrency: jobs: publish: + # Skip fork PRs — secrets (CLAWHUB_TOKEN) are not available + if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name != 'pull_request' runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb44bd48..6de7cbc5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,333 +1,227 @@ -# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# Release workflow for @googleworkspace/cli # -# Copyright 2022-2024, axodotdev -# SPDX-License-Identifier: MIT or Apache-2.0 -# -# CI that: -# -# * checks for a Git Tag that looks like a release -# * builds artifacts with dist (archives, installers, hashes) -# * uploads those artifacts to temporary workflow zip -# * on success, uploads the artifacts to a GitHub Release -# -# Note that the GitHub Release will be created with a generated -# title/body based on your changelogs. +# Triggered by pushing a semver tag (e.g. v0.22.3). +# Builds platform binaries, creates a GitHub Release, publishes to npm and crates.io. name: Release permissions: - "contents": "write" + contents: write + attestations: write + id-token: write -# This task will run whenever you push a git tag that looks like a version -# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. -# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where -# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION -# must be a Cargo-style SemVer Version (must have at least major.minor.patch). -# -# If PACKAGE_NAME is specified, then the announcement will be for that -# package (erroring out if it doesn't have the given version or isn't dist-able). -# -# If PACKAGE_NAME isn't specified, then the announcement will be for all -# (dist-able) packages in the workspace with that version (this mode is -# intended for workspaces with only one dist-able package, or with all dist-able -# packages versioned/released in lockstep). -# -# If you push multiple tags at once, separate instances of this workflow will -# spin up, creating an independent announcement for each one. However, GitHub -# will hard limit this to 3 tags per commit, as it will assume more tags is a -# mistake. -# -# If there's a prerelease-style suffix to the version, then the release(s) -# will be marked as a prerelease. on: pull_request: push: tags: - - '**[0-9]+.[0-9]+.[0-9]+*' + - 'v[0-9]+.[0-9]+.[0-9]+*' + +env: + CARGO_TERM_COLOR: always jobs: - # Run 'dist plan' (or host) to determine what tasks we need to do plan: - runs-on: "ubuntu-22.04" + runs-on: ubuntu-latest outputs: - val: ${{ steps.plan.outputs.manifest }} - tag: ${{ !github.event.pull_request && github.ref_name || '' }} - tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} - publishing: ${{ !github.event.pull_request }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.meta.outputs.version }} + prerelease: ${{ steps.meta.outputs.prerelease }} + publishing: ${{ github.ref_type == 'tag' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install dist - # we specify bash to get pipefail; it guards against the `curl` command - # failing. otherwise `sh` won't catch that `curl` returned non-0 - shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" - - name: Cache dist - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/dist - # sure would be cool if github gave us proper conditionals... - # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible - # functionality based on whether this is a pull_request, and whether it's from a fork. - # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* - # but also really annoying to build CI around when it needs secrets to work right.) - - id: plan + - id: meta run: | - dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json> plan-dist-manifest.json - echo "dist ran successfully" - cat plan-dist-manifest.json - echo "manifest=$(jq -c "." plan-dist-manifest.json)">> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: artifacts-plan-dist-manifest - path: plan-dist-manifest.json + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + echo "version=${VERSION}">> "$GITHUB_OUTPUT" + if [[ "$VERSION" == *-* ]]; then + echo "prerelease=true">> "$GITHUB_OUTPUT" + else + echo "prerelease=false">> "$GITHUB_OUTPUT" + fi - # Build and packages all the platform-specific things - build-local-artifacts: - name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) - # Let the initial task tell us to not run (currently very blunt) - needs: - - plan - if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + build: + needs: plan strategy: fail-fast: false - # Target platforms/runners are computed by dist in create-release. - # Each member of the matrix has the following arguments: - # - # - runner: the github runner - # - dist-args: cli flags to pass to dist - # - install-dist: expression to run to install dist on the runner - # - # Typically there will be: - # - 1 "global" task that builds universal installers - # - N "local" tasks that build each platform's binaries and platform-specific installers - matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-latest + archive: tar.gz + - target: x86_64-apple-darwin + runner: macos-latest + archive: tar.gz + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + archive: tar.gz + cross: true + - target: aarch64-unknown-linux-musl + runner: ubuntu-latest + archive: tar.gz + cross: true + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + archive: tar.gz + - target: x86_64-unknown-linux-musl + runner: ubuntu-latest + archive: tar.gz + cross: true + - target: x86_64-pc-windows-msvc + runner: windows-latest + archive: zip runs-on: ${{ matrix.runner }} - container: ${{ matrix.container && matrix.container.image || null }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json - permissions: - "attestations": "write" - "contents": "read" - "id-token": "write" steps: - - name: enable windows longpaths - run: | - git config --global core.longpaths true - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable with: - persist-credentials: false - submodules: recursive - - name: Install Rust non-interactively if not already installed - if: ${{ matrix.container }} + targets: ${{ matrix.target }} + + - name: Install cross + if: matrix.cross + run: cargo install cross --git https://github.com/cross-rs/cross --tag v0.2.5 + + - name: Build run: | - if ! command -v cargo> /dev/null 2>&1; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - echo "$HOME/.cargo/bin">> $GITHUB_PATH + if [ "${{ matrix.cross }}" = "true" ]; then + cross build --release --target ${{ matrix.target }} --locked + else + cargo build --release --target ${{ matrix.target }} --locked fi - - name: Install dist - run: ${{ matrix.install_dist.run }} - # Get the dist-manifest - - name: Fetch local artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - name: Install dependencies + shell: bash + + - name: Package (unix) + if: matrix.archive == 'tar.gz' run: | - ${{ matrix.packages_install }} - - name: Build artifacts + ARTIFACT="google-workspace-cli-${{ matrix.target }}" + mkdir -p staging + cp "target/${{ matrix.target }}/release/gws" staging/ + cp LICENSE README.md CHANGELOG.md staging/ + tar czf "${ARTIFACT}.tar.gz" -C staging . + shasum -a 256 "${ARTIFACT}.tar.gz"> "${ARTIFACT}.tar.gz.sha256" + shell: bash + + - name: Package (windows) + if: matrix.archive == 'zip' run: | - # Actually do builds and make zips and whatnot - dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }}> dist-manifest.json - echo "dist ran successfully" + $ARTIFACT = "google-workspace-cli-${{ matrix.target }}" + New-Item -ItemType Directory -Path $ARTIFACT + Copy-Item "target/${{ matrix.target }}/release/gws.exe" "$ARTIFACT/" + Copy-Item LICENSE, README.md, CHANGELOG.md "$ARTIFACT/" + Compress-Archive -Path "$ARTIFACT/*" -DestinationPath "$ARTIFACT.zip" + (Get-FileHash "$ARTIFACT.zip" -Algorithm SHA256).Hash.ToLower() + " $ARTIFACT.zip" | Out-File -Encoding ascii "$ARTIFACT.zip.sha256" + shell: pwsh + - name: Attest + if: github.ref_type == 'tag' uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 with: - subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" - - id: cargo-dist - name: Post-build - # We force bash here just because github makes it really hard to get values up - # to "real" actions without writing to env-vars, and writing to env-vars has - # inconsistent syntax between shell and powershell. - shell: bash - run: | - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - dist print-upload-files-from-manifest --manifest dist-manifest.json>> "$GITHUB_OUTPUT" - echo "EOF">> "$GITHUB_OUTPUT" + subject-path: google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }} - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: artifacts-build-local-${{ join(matrix.targets, '_') }} + name: binary-${{ matrix.target }} path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} + google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }} + google-workspace-cli-${{ matrix.target }}.${{ matrix.archive }}.sha256 - # Build and package all the platform-agnostic(ish) things - build-global-artifacts: - needs: - - plan - - build-local-artifacts - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + release: + needs: [plan, build] + if: github.ref_type == 'tag' + runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Get all the local artifacts for the global tasks to use (for e.g. checksums) - - name: Fetch local artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - id: cargo-dist - shell: bash - run: | - dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global"> dist-manifest.json - echo "dist ran successfully" - - # Parse out what we just built and upload it to scratch storage - echo "paths<> "$GITHUB_OUTPUT" - jq --raw-output ".upload_files[]" dist-manifest.json>> "$GITHUB_OUTPUT" - echo "EOF">> "$GITHUB_OUTPUT" + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - cp dist-manifest.json "$BUILD_MANIFEST_NAME" - - name: "Upload artifacts" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: artifacts-build-global - path: | - ${{ steps.cargo-dist.outputs.paths }} - ${{ env.BUILD_MANIFEST_NAME }} - # Determines if we should publish/announce - host: - needs: - - plan - - build-local-artifacts - - build-global-artifacts - # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) - if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - runs-on: "ubuntu-22.04" - outputs: - val: ${{ steps.host.outputs.manifest }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive - - name: Install cached dist - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: cargo-dist-cache - path: ~/.cargo/bin/ - - run: chmod +x ~/.cargo/bin/dist - # Fetch artifacts from scratch-storage - - name: Fetch artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: target/distrib/ - merge-multiple: true - - id: host - shell: bash - run: | - dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json> dist-manifest.json - echo "artifacts uploaded and released successfully" - cat dist-manifest.json - echo "manifest=$(jq -c "." dist-manifest.json)">> "$GITHUB_OUTPUT" - - name: "Upload dist-manifest.json" - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - # Overwrite the previous copy - name: artifacts-dist-manifest - path: dist-manifest.json - # Create a GitHub Release while uploading all files to it - - name: "Download GitHub Artifacts" - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - pattern: artifacts-* - path: artifacts + pattern: binary-* + path: artifacts/ merge-multiple: true - - name: Cleanup - run: | - # Remove the granular manifests - rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release env: - PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" - ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" - ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" - RELEASE_COMMIT: "${{ github.sha }}" + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Write and read notes from a file to avoid quoting breaking things - echo "$ANNOUNCEMENT_BODY"> $RUNNER_TEMP/notes.txt + PRERELEASE_FLAG="" + if [ "${{ needs.plan.outputs.prerelease }}" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + fi + + NOTES="## Installation + + Download the archive for your OS and architecture from the assets below. + + ### Linux / macOS + + Replace \`\` with your platform (e.g., \`aarch64-apple-darwin\` or \`x86_64-unknown-linux-gnu\`). + + \`\`\`bash + # 1. Download the archive and its checksum + curl -sLO https://github.com/googleworkspace/cli/releases/download/${{ github.ref_name }}/google-workspace-cli-.tar.gz + curl -sLO https://github.com/googleworkspace/cli/releases/download/${{ github.ref_name }}/google-workspace-cli-.tar.gz.sha256 + + # 2. Verify the checksum + shasum -a 256 -c google-workspace-cli-.tar.gz.sha256 + + # 3. Extract and install + tar -xzf google-workspace-cli-.tar.gz + chmod +x gws + sudo mv gws /usr/local/bin/ + \`\`\` + + ### Windows + + 1. Download \`google-workspace-cli-x86_64-pc-windows-msvc.zip\` and its \`.sha256\` file + 2. Verify the checksum (e.g., using PowerShell \`Get-FileHash\`) + 3. Extract the archive and move \`gws.exe\` to a directory included in your system \`PATH\`. + + --- + " - gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + gh release create "${{ github.ref_name }}" \ + --target "${{ github.sha }}" \ + --title "${{ github.ref_name }}" \ + --notes "$NOTES" \ + --generate-notes \ + $PRERELEASE_FLAG \ + artifacts/* publish-npm: - needs: - - plan - - host - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PLAN: ${{ needs.plan.outputs.val }} - if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }} + needs: [plan, release] + runs-on: ubuntu-latest + if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }} steps: - - name: Fetch npm packages - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: artifacts-* - path: npm/ - merge-multiple: true + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 with: node-version: '20.x' registry-url: 'https://wombat-dressing-room.appspot.com' - - run: | - for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do - pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output) - npm publish --access public "./npm/${pkg}" - done + + - name: Publish to npm + working-directory: npm + run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - announce: - needs: - - plan - - host - - publish-npm - # use "always() && ..." to allow us to wait for all publish jobs while - # still allowing individual publish jobs to skip themselves (for prereleases). - # "host" however must run to completion, no skipping allowed! - if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }} - runs-on: "ubuntu-22.04" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + publish-cargo: + needs: [plan, release] + runs-on: ubuntu-latest + if: ${{ needs.plan.outputs.publishing == 'true' && needs.plan.outputs.prerelease == 'false' }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - submodules: recursive + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + + # Publish library crate first (CLI depends on it) + - name: Publish google-workspace + run: cargo publish --package google-workspace + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + # Wait for crates.io to index the library crate + - name: Wait for crates.io index + run: sleep 30 + + - name: Publish google-workspace-cli + run: cargo publish --package google-workspace-cli + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 122cdffe..72211226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ `gws` is a Rust CLI tool for interacting with Google Workspace APIs. It dynamically generates its command surface at runtime by parsing Google Discovery Service JSON documents. > [!IMPORTANT] -> **Dynamic Discovery**: This project does NOT use generated Rust crates (e.g., `google-drive3`) for API interaction. Instead, it fetches the Discovery JSON at runtime and builds `clap` commands dynamically. When adding a new service, you only need to register it in `src/services.rs` and verify the Discovery URL pattern in `src/discovery.rs`. Do NOT add new crates to `Cargo.toml` for standard Google APIs. +> **Dynamic Discovery**: This project does NOT use generated Rust crates (e.g., `google-drive3`) for API interaction. Instead, it fetches the Discovery JSON at runtime and builds `clap` commands dynamically. When adding a new service, you only need to register it in `crates/google-workspace/src/services.rs` and verify the Discovery URL pattern in `crates/google-workspace/src/discovery.rs`. Do NOT add new crates to `Cargo.toml` for standard Google APIs. > [!NOTE] > **Package Manager**: Use `pnpm` instead of `npm` for Node.js package management in this repository. @@ -42,22 +42,38 @@ The CLI uses a **two-phase argument parsing** strategy: 1. Parse argv to extract the service name (e.g., `drive`) 2. Fetch the service's Discovery Document, build a dynamic `clap::Command` tree, then re-parse -### Source Layout - -| File | Purpose | -| ------------------------- | ----------------------------------------------------------------------------------------- | -| `src/main.rs` | Entrypoint, two-phase CLI parsing, method resolution | -| `src/discovery.rs` | Serde models for Discovery Document + fetch/cache | -| `src/services.rs` | Service alias → Discovery API name/version mapping | -| `src/auth.rs` | OAuth2 token acquisition via env vars, encrypted credentials, or ADC | -| `src/credential_store.rs` | AES-256-GCM encryption/decryption of credential files | -| `src/auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `setup`, `status`, `export` | -| `src/commands.rs` | Recursive `clap::Command` builder from Discovery resources | -| `src/executor.rs` | HTTP request construction, response handling, schema validation | -| `src/schema.rs` | `gws schema` command — introspect API method schemas | -| `src/error.rs` | Structured JSON error output | -| `src/logging.rs` | Opt-in structured logging (stderr + file) via `tracing` | -| `src/timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API, 24h cache | +### Workspace Layout + +The repository is a Cargo workspace with two crates: + +| Crate | Package | Purpose | +| ------------------------------ | ----------------------- | ------------------------------------------------- | +| `crates/google-workspace/` | `google-workspace` | Publishable library — core types and helpers | +| `crates/google-workspace-cli/` | `google-workspace-cli` | Binary crate — the `gws` CLI | + +#### Library (`crates/google-workspace/src/`) + +| File | Purpose | +| ---------------- | ---------------------------------------------------------- | +| `discovery.rs` | Serde models for Discovery Document + async fetch/cache | +| `services.rs` | Service alias → Discovery API name/version mapping | +| `error.rs` | `GwsError` enum, exit codes, JSON serialization | +| `validate.rs` | Path/URL/resource validators, `encode_path_segment()` | +| `client.rs` | HTTP client with retry logic | + +#### CLI (`crates/google-workspace-cli/src/`) + +| File | Purpose | +| ------------------- | ------------------------------------------------------------------------ | +| `main.rs` | Entrypoint, two-phase CLI parsing, method resolution | +| `auth.rs` | OAuth2 token acquisition via env vars, encrypted credentials, or ADC | +| `credential_store.rs` | AES-256-GCM encryption/decryption of credential files | +| `auth_commands.rs` | `gws auth` subcommands: `login`, `logout`, `setup`, `status`, `export` | +| `commands.rs` | Recursive `clap::Command` builder from Discovery resources | +| `executor.rs` | HTTP request construction, response handling, schema validation | +| `schema.rs` | `gws schema` command — introspect API method schemas | +| `logging.rs` | Opt-in structured logging (stderr + file) via `tracing` | +| `timezone.rs` | Account timezone resolution: `--timezone` flag, Calendar Settings API | ## Demo Videos @@ -88,7 +104,7 @@ ASCII art title cards live in `art/`. The `scripts/show-art.sh` helper clears th > [!NOTE] > **Environment variables are trusted inputs.** The validation rules above apply to **CLI arguments** that may be passed by untrusted AI agents. Environment variables (e.g. `GOOGLE_WORKSPACE_CLI_CONFIG_DIR`) are set by the user themselves — in their shell profile, `.env` file, or deployment config — and are not subject to path traversal validation. This is consistent with standard conventions like `XDG_CONFIG_HOME`, `CARGO_HOME`, etc. -### Path Safety (`src/validate.rs`) +### Path Safety (`crates/google-workspace/src/validate.rs`) When adding new helpers or CLI flags that accept file paths, **always validate** using the shared helpers: @@ -106,7 +122,7 @@ if let Some(output_dir) = matches.get_one::("output-dir") { } ``` -### URL Encoding (`src/helpers/mod.rs`) +### URL Encoding (`crates/google-workspace-cli/src/helpers/mod.rs`) User-supplied values embedded in URL **path segments** must be percent-encoded. Use the shared helper: @@ -131,13 +147,13 @@ client.get(url).query(&[("q", user_query)]).send().await?; let url = format!("{}?q={}", base_url, user_query); ``` -### Resource Name Validation (`src/helpers/mod.rs`) +### Resource Name Validation (`crates/google-workspace-cli/src/helpers/mod.rs`) When a user-supplied string is used as a GCP resource identifier (project ID, topic name, space name, etc.) that gets embedded in a URL path, validate it first: ```rust // Validates the string does not contain path traversal segments (`..`), control characters, or URL-breaking characters like `?` and `#`. -let project = crate::helpers::validate_resource_name(&project_id)?; +let project = crate::validate::validate_resource_name(&project_id)?; let url = format!("https://pubsub.googleapis.com/v1/projects/{}/topics/my-topic", project); ``` @@ -162,10 +178,19 @@ Use these labels to categorize pull requests and issues: - `area: http` — Request execution, URL building, response handling - `area: docs` — README, contributing guides, documentation - `area: tui` — Setup wizard, picker, input fields -- `area: distribution` — Nix flake, cargo-dist, npm packaging, install methods +- `area: distribution` — Nix flake, npm packaging, GitHub Actions release workflow, install methods - `area: auth` — OAuth, credentials, multi-account, ADC - `area: skills` — AI skill generation and management +## Helper Commands (`+verb`) + +Helpers are handwritten commands prefixed with `+` that provide value the schema-driven Discovery commands cannot: multi-step orchestration, format translation (e.g., Markdown → Docs JSON), or multi-API composition. + +> [!IMPORTANT] +> **Do NOT add a helper that** wraps a single API call already available via Discovery, adds flags to expose data already in the API response, or re-implements Discovery parameters as custom flags. Helper flags must control orchestration logic — use `--params` and `--format`/`jq` for API parameters and output filtering. + +See [`src/helpers/README.md`](crates/google-workspace-cli/src/helpers/README.md) for full guidelines, anti-patterns, and a checklist for new helpers. + ## Environment Variables ### Authentication diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e9c132..f332f4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,220 @@ # @googleworkspace/cli +## 0.22.5 + +### Patch Changes + +- 5d24ac2: Add cargo-audit CI workflow for automated dependency vulnerability scanning +- ecddf2e: Add cargo-deny configuration for license, advisory, and source auditing +- 503315b: Update installation instructions to prioritize GitHub Releases over npm +- 6ccbb42: fix: auto-install binary on run if missing + + pnpm skips postinstall when the package is already up to date. + This ensures run.js will auto-trigger install.js if the + binary is missing, fixing the 'gws binary not found' error. + +- b307856: Migrated the internal AI skills registry (personas and recipes) from YAML to TOML. This allows us to drop the unmaintained serde_yaml dependency, improving the project's supply chain security posture. +- 158f93a: Verify SHA256 checksum of downloaded binary in npm postinstall script +- b422e5d: Pin cross-rs to v0.2.5 in release workflow to prevent unpinned git HEAD builds + +## 0.22.4 + +### Patch Changes + +- 86c08cf: Remove cargo-dist; use native Node.js fetch for npm binary installer + + Replaces the cargo-dist generated release pipeline and npm package with: + + - A custom GitHub Actions release workflow with matrix cross-compilation + - A zero-dependency npm installer using native `fetch()` (Node 18+) + - Removes axios, rimraf, detect-libc, console.table, and axios-proxy-builder dependencies from the published npm package + +## 0.22.3 + +### Patch Changes + +- 674d53a: Fix `Lint Skills` CI job by installing `uv` via `astral-sh/setup-uv` before running `uvx` +- c7c42f6: fix: register script service and resolve test path validation errors +- 80bd150: feat(auth): use strict OS keychain integration on macOS and Windows + + Closes #623. The CLI no longer writes a fallback `.encryption_key` text file on macOS and Windows when securely storing credentials. Instead, it strictly uses the native OS keychain (Keychain Access on macOS, Credential Manager on Windows). If an old `.encryption_key` file is found during a successful keychain login, it will be automatically deleted for security. + Linux deployments continue to use a seamless file-based fallback by default to ensure maximum compatibility with headless continuous integration (CI) runners, Docker containers, and SSH environments without desktop DBUS services. + +- ec7f56b: Sync generated skills with latest Google Discovery API specs + +## 0.22.2 + +### Patch Changes + +- a52d297: Improve proxy-aware OAuth flows and clean up review feedback for auth login. + +## 0.22.1 + +### Patch Changes + +- 6a45832: Sync generated skills with latest Google Discovery API specs + +## 0.22.0 + +### Minor Changes + +- 0850c48: Add `--draft` flag to Gmail `+send`, `+reply`, `+reply-all`, and `+forward` helpers to save messages as drafts instead of sending them immediately + +## 0.21.2 + +### Patch Changes + +- c4448b9: Add crates.io publishing to release workflow + + Publishes both `google-workspace` and `google-workspace-cli` to crates.io on each release. The library crate is published first (as a dependency), followed by the CLI crate. + +## 0.21.1 + +### Patch Changes + +- ea0849a: Fix version-sync script and bump CLI crate version to 0.21.0 + + The `version-sync.sh` script was updating the root `Cargo.toml` which no longer has a `[package]` section after the workspace refactor. Updated to target `crates/google-workspace-cli/Cargo.toml`. Also syncs the CLI crate version to 0.21.0 to match `package.json`. + +## 0.21.0 + +### Minor Changes + +- 029e5de: Extract `google-workspace` library crate for programmatic Rust API access (closes #386) + + Introduces a Cargo workspace with a new `google-workspace` library crate (`crates/google-workspace/`) + that exposes the core modules for use as a Rust dependency: + + - `discovery` — Discovery Document types and fetching + - `error` — Structured `GwsError` type + - `services` — Service registry and resolution + - `validate` — Input validation and URL encoding + - `client` — HTTP client with retry logic + + The `gws` binary crate re-exports all library types transparently — zero behavioral changes. + +## 0.20.1 + +### Patch Changes + +- b8fd3d9: fix(client): add 10s connect timeout to prevent hangs on initial connection +- 2bfcca9: Move version from top-level SKILL.md frontmatter to metadata and track CLI version +- 2ddb46e: test(gmail): add regression tests for RFC 2822 display name quoting +- 75a7121: Sync generated skills with latest Google Discovery API specs + +## 0.20.0 + +### Minor Changes + +- e782dd7: Forward original attachments by default and preserve inline images in HTML mode. + + `+forward` now includes the original message's attachments and inline images by default, + matching Gmail web behavior. Use `--no-original-attachments` to opt out. + `+reply`/`+reply-all` with `--html` preserve inline images in the quoted body via + `multipart/related`. In plain-text mode, inline images are not included (matching Gmail web). + +## 0.19.0 + +### Minor Changes + +- a078945: Refactor all `gws auth` subcommands to use clap for argument parsing + + Replace manual argument parsing in `handle_auth_command`, `handle_login`, `resolve_scopes`, and `handle_export` with structured `clap::Command` definitions. Introduces `ScopeMode` enum for type-safe scope selection and adds proper `--help` support for all auth subcommands. + +### Patch Changes + +- 8a749c2: feat(helpers): add --dry-run support to events helper commands + + Add dry-run mode to `gws events +renew` and `gws events +subscribe` commands. + When --dry-run is specified, the commands will print what actions would be + taken without making any API calls. This allows agents to simulate requests + and learn without reaching the server. + +- d679401: Fix `mask_secret` panic on multi-byte UTF-8 secrets by using char-based indexing instead of byte-offset slicing +- d341de2: Handle --help/-h in `gws auth setup` before launching the setup wizard, preventing accidental project creation when users just want usage info +- f157208: fix: use block-style YAML sequences in generated SKILL.md frontmatter + + Replace flow sequences (`bins: ["gws"]`, `skills: [...]`) with block-style + sequences (`bins:\n - gws`) 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. + + Fixes #521 + +- b4d5e26: Fix auth error propagation: properly propagate errors when token directory creation or permission setting fails, instead of silently ignoring them + +## 0.18.1 + +### Patch Changes + +- a87037b: Handle SIGTERM in `gws gmail +watch` and `gws events +subscribe` for clean container shutdown. + + Long-running pull loops now exit gracefully on SIGTERM (in addition to Ctrl+C), + enabling clean shutdown under Kubernetes, Docker, and systemd. + +## 0.18.0 + +### Minor Changes + +- 908cf73: feat(gmail): auto-populate From header with display name from send-as settings + + Fetch the user's send-as identities to set the From header with a display name in all mail helpers (+send, +reply, +reply-all, +forward), matching Gmail web client behavior. Also enriches bare `--from` emails with their configured display name. + +- 6e4daaf: Gmail helpers rollup: mail-builder migration, --attach flag (upload endpoint), +read helper + + - Migrate `+send`, `+reply`, `+reply-all`, and `+forward` to the `mail-builder` crate for RFC-compliant MIME construction + - Add `--from` flag to `+send` for send-as alias support + - Add `-a`/`--attach` flag to all mail helpers (`+send`, `+reply`, `+reply-all`, `+forward`) with `mime_guess2` auto-detection, 25MB size validation, and upload endpoint support (35MB API limit vs 5MB metadata-only) + - Add `+read` helper to extract message body and headers (text, HTML, or JSON output) + - Make `OriginalMessage.thread_id` optional (`Option`) for draft compatibility + - RFC 2822 display name quoting is handled natively by `mail-builder` + - Introduce `UploadSource` enum in executor for type-safe upload strategies + +### Patch Changes + +- 1e90380: fix(gmail): remove dead `--attachment` arg from `+send` + + The `+send` subcommand defined a duplicate `"attachment"` arg alongside the + `"attach"` arg already provided by `common_mail_args`. Since `parse_attachments` + reads `"attach"`, the `--attachment` flag was silently ignored. Removed the + dead duplicate. + +- 908cf73: fix(gmail): handle reply-all to own message correctly + + Reply-all to a message you sent no longer errors with "No To recipient remains." The original To recipients are now used as reply targets, matching Gmail web client behavior. + +- 2e909ae: Consolidate terminal sanitization, coloring, and output helpers into a new `output.rs` module. Fixes raw ANSI escape codes in `watch.rs` that bypassed `NO_COLOR` and TTY detection, upgrades `sanitize_for_terminal` to also strip dangerous Unicode characters (bidi overrides, zero-width spaces, directional isolates), and sanitizes previously raw API error body and user query outputs. + +## 0.17.0 + +### Minor Changes + +- 1b0a21f: feat: support google meet video conferencing in calendar +insert + +### Patch Changes + +- 811fe7b: Fix critical security vulnerability (TOCTOU/Symlink race) in atomic file writes. + + The atomic_write and atomic_write_async utilities now use: + + - Randomized temporary filenames to prevent predictability. + - O_EXCL creation flags to prevent following pre-existing symlinks. + - Strict 0600 permissions from the moment of file creation on Unix systems. + - Redundant post-write permission calls have been removed to close race windows. + +- b241a5b: fix(security): cap Retry-After sleep, sanitize upload mimeType, and validate --upload/--output paths +- 6f92e5b: Stderr/output hygiene rollup: route diagnostics to stderr, add colored error labels, propagate auth errors. + + - **triage.rs**: "No messages found" sent to stderr so stdout stays valid JSON for pipes + - **modelarmor.rs**: response body printed only on success; error message now includes body for diagnostics + - **error.rs**: colored `error[variant]:` labels on stderr (respects `NO_COLOR` env var), `hint:` prefix for accessNotConfigured guidance + - **calendar, chat, docs, drive, script, sheets**: auth failures now propagate as `GwsError::Auth` instead of silently proceeding unauthenticated (dry-run still works without auth) + +- 398e80c: Sync generated skills with latest Google Discovery API specs +- 8458104: Extend input validation to reject dangerous Unicode characters (zero-width chars, bidi overrides, Unicode line/paragraph separators) that were not caught by the previous ASCII-range check + ## 0.16.0 ### Minor Changes diff --git a/Cargo.lock b/Cargo.lock index 0d55ea8d..b7e7baec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,9 +226,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -674,9 +674,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -834,6 +834,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -885,8 +895,24 @@ dependencies = [ ] [[package]] -name = "gws" -version = "0.16.0" +name = "google-workspace" +version = "0.22.5" +dependencies = [ + "anyhow", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serial_test", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "google-workspace-cli" +version = "0.22.5" dependencies = [ "aes-gcm", "anyhow", @@ -901,25 +927,29 @@ dependencies = [ "dirs", "dotenvy", "futures-util", + "google-workspace", "hostname", "iana-time-zone", "keyring", + "mail-builder", + "mime_guess2", "percent-encoding", "rand 0.8.5", "ratatui", "reqwest", "serde", "serde_json", - "serde_yaml", "serial_test", "sha2", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "toml", "tracing", "tracing-appender", "tracing-subscriber", + "uuid", "yup-oauth2", "zeroize", ] @@ -1033,9 +1063,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1048,7 +1078,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1264,9 +1293,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling 0.23.0", "indoc", @@ -1283,9 +1312,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1308,16 +1337,18 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1373,18 +1404,18 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] [[package]] name = "line-clipping" -version = "0.3.5" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ "bitflags 2.11.0", ] @@ -1447,6 +1478,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "mail-builder" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900998f307338c4013a28ab14d760b784067324b164448c6d98a89e44810473b" +dependencies = [ + "gethostname", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1477,6 +1517,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf 0.11.3", + "phf_shared 0.11.3", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1485,9 +1543,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -1529,9 +1587,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-derive" @@ -1723,6 +1781,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", + "unicase", ] [[package]] @@ -1732,6 +1791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", + "unicase", ] [[package]] @@ -1749,12 +1809,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "polyval" version = "0.6.2" @@ -2142,9 +2196,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -2206,9 +2260,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -2349,28 +2403,24 @@ dependencies = [ ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "serde_spanned" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", "serde", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "indexmap", + "form_urlencoded", "itoa", "ryu", "serde", - "unsafe-libyaml", ] [[package]] @@ -2399,6 +2449,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2741,9 +2797,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2805,6 +2861,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" @@ -2954,6 +3051,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2962,9 +3065,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -2999,12 +3102,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -3037,13 +3134,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "atomic", "getrandom 0.4.2", "js-sys", + "sha1_smol", "wasm-bindgen", ] @@ -3103,9 +3201,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -3116,23 +3214,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "19280959e2844181895ef62f065c63e0ca07ece4771b53d89bfdb967d97cbf05" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3140,9 +3234,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -3153,9 +3247,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] @@ -3209,9 +3303,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "749466a37ee189057f54748b200186b59a03417a117267baf3fd89cecc9fb837" dependencies = [ "js-sys", "wasm-bindgen", @@ -3602,6 +3696,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3746,18 +3849,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 24bc253b..5df4c987 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,73 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -[package] -name = "gws" -version = "0.16.0" -edition = "2021" -description = "Google Workspace CLI — dynamic command surface from Discovery Service" -license = "Apache-2.0" -repository = "https://github.com/googleworkspace/cli" -homepage = "https://github.com/googleworkspace/cli" -readme = "README.md" -authors = ["Justin Poehnelt"] -keywords = ["cli", "google-workspace", "google", "drive", "gmail"] -categories = ["command-line-utilities", "web-programming"] - -[[bin]] -name = "gws" -path = "src/main.rs" - - - -[dependencies] -aes-gcm = "0.10" -anyhow = "1" -clap = { version = "4", features = ["derive", "string"] } -dirs = "5" -dotenvy = "0.15" -hostname = "0.4" -reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots"], default-features = false } -rand = "0.8" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" -thiserror = "2" -tokio = { version = "1", features = ["full"] } -yup-oauth2 = "12" -futures-util = "0.3" -tokio-util = { version = "0.7", features = ["io"] } -bytes = "1" -base64 = "0.22.1" -derive_builder = "0.20.2" -ratatui = "0.30.0" -crossterm = "0.29.0" -chrono = "0.4.44" -chrono-tz = "0.10" -iana-time-zone = "0.1" -async-trait = "0.1.89" -serde_yaml = "0.9.34" -percent-encoding = "2.3.2" -zeroize = { version = "1.8.2", features = ["derive"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } -tracing-appender = "0.2" - -[target.'cfg(target_os = "macos")'.dependencies] -keyring = { version = "3.6.3", features = ["apple-native"] } - -[target.'cfg(target_os = "windows")'.dependencies] -keyring = { version = "3.6.3", features = ["windows-native"] } - -[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies] -keyring = "3.6.3" - +[workspace] +members = ["crates/google-workspace-cli", "crates/google-workspace"] +resolver = "2" # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" - -[dev-dependencies] -serial_test = "3.4.0" -tempfile = "3" diff --git a/README.md b/README.md index 77fafb67..04c532d0 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,7 @@ Drive, Gmail, Calendar, and every Workspace API. Zero boilerplate. Structured JS


-```bash -npm install -g @googleworkspace/cli -``` +⬇️ **[Download the latest release for your OS](https://github.com/googleworkspace/cli/releases)** `gws` doesn't ship a static list of commands. It reads Google's own [Discovery Service](https://developers.google.com/discovery) at runtime and builds its entire command surface dynamically. When Google Workspace adds an API endpoint or method, `gws` picks it up automatically. @@ -46,15 +44,14 @@ npm install -g @googleworkspace/cli ## Installation +The recommended way to install `gws` is to download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page. Extract the archive and place the `gws` binary in your `$PATH`. + +For convenience, you can also use `npm` to automate downloading the appropriate binary from GitHub Releases: + ```bash npm install -g @googleworkspace/cli ``` -> The npm package bundles pre-built native binaries for your OS and architecture. -> No Rust toolchain required. - -Pre-built binaries are also available on the [GitHub Releases](https://github.com/googleworkspace/cli/releases) page. - Or build from source: ```bash @@ -382,7 +379,6 @@ All variables are optional. See [`.env.example`](.env.example) for a copy-paste |---|---| | `GOOGLE_WORKSPACE_CLI_TOKEN` | Pre-obtained OAuth2 access token (highest priority) | | `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE` | Path to OAuth credentials JSON (user or service account) | - | `GOOGLE_WORKSPACE_CLI_CLIENT_ID` | OAuth client ID (alternative to `client_secret.json`) | | `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET` | OAuth client secret (paired with `CLIENT_ID`) | | `GOOGLE_WORKSPACE_CLI_CONFIG_DIR` | Override config directory (default: `~/.config/gws`) | diff --git a/crates/google-workspace-cli/Cargo.toml b/crates/google-workspace-cli/Cargo.toml new file mode 100644 index 00000000..058b109e --- /dev/null +++ b/crates/google-workspace-cli/Cargo.toml @@ -0,0 +1,80 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "google-workspace-cli" +version = "0.22.5" +edition = "2021" +description = "Google Workspace CLI — dynamic command surface from Discovery Service" +license = "Apache-2.0" +repository = "https://github.com/googleworkspace/cli" +homepage = "https://github.com/googleworkspace/cli" +readme = "README.md" +authors = ["Justin Poehnelt"] +keywords = ["cli", "google-workspace", "google", "drive", "gmail"] +categories = ["command-line-utilities", "web-programming"] + +[[bin]] +name = "gws" +path = "src/main.rs" + +[dependencies] +google-workspace = { version = "0.22.5", path = "../google-workspace" } +tempfile = "3" +aes-gcm = "0.10" +anyhow = "1" +clap = { version = "4", features = ["derive", "string"] } +dirs = "5" +dotenvy = "0.15" +hostname = "0.4" +reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"], default-features = false } +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +yup-oauth2 = "12" +futures-util = "0.3" +tokio-util = { version = "0.7", features = ["io"] } +bytes = "1" +base64 = "0.22.1" +derive_builder = "0.20.2" +ratatui = "0.30.0" +crossterm = "0.29.0" +chrono = "0.4.44" +chrono-tz = "0.10" +iana-time-zone = "0.1" +mail-builder = "0.4" +async-trait = "0.1.89" +toml = "0.8" +percent-encoding = "2.3.2" +zeroize = { version = "1.8.2", features = ["derive"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" +uuid = { version = "1.22.0", features = ["v4", "v5"] } +mime_guess2 = "2.3.1" + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3.6.3", features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3.6.3", features = ["windows-native"] } + +[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies] +keyring = "3.6.3" + +[dev-dependencies] +serial_test = "3.4.0" diff --git a/crates/google-workspace-cli/README.md b/crates/google-workspace-cli/README.md new file mode 100644 index 00000000..901fae46 --- /dev/null +++ b/crates/google-workspace-cli/README.md @@ -0,0 +1,33 @@ +# google-workspace-cli + +**One CLI for all of Google Workspace — built for humans and AI agents.** + +`gws` dynamically generates its command surface at runtime by reading Google's [Discovery Service](https://developers.google.com/discovery). Drive, Gmail, Calendar, and every Workspace API — zero boilerplate, structured JSON output, 40+ agent skills included. + +## Install + +Download the pre-built binary for your OS and architecture from the **[GitHub Releases](https://github.com/googleworkspace/cli/releases)** page. + +Alternatively, you can use package managers as a convenience layer: + +```bash +npm install -g @googleworkspace/cli # npm (downloads GitHub release binary) +cargo install google-workspace-cli # crates.io +nix run github:googleworkspace/cli # nix +``` + +## Quick Start + +```bash +gws auth login +gws drive files list --params '{"pageSize": 5}' +gws gmail users.messages list --params '{"maxResults": 3}' +``` + +## Documentation + +See the [full README](https://github.com/googleworkspace/cli#readme) for authentication setup, helper commands, agent skills, and more. + +## License + +Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE). diff --git a/crates/google-workspace-cli/registry/personas.toml b/crates/google-workspace-cli/registry/personas.toml new file mode 100644 index 00000000..5912bead --- /dev/null +++ b/crates/google-workspace-cli/registry/personas.toml @@ -0,0 +1,190 @@ +[[personas]] +name = "exec-assistant" +title = "Executive Assistant" +description = "Manage an executive's schedule, inbox, and communications." +services = [ "gmail", "calendar", "drive", "chat" ] +workflows = [ "+standup-report", "+meeting-prep", "+weekly-digest" ] +instructions = [ + "Start each day with `gws workflow +standup-report` to get the executive's agenda and open tasks.", + "Before each meeting, run `gws workflow +meeting-prep` to see attendees, description, and linked docs.", + "Triage the inbox with `gws gmail +triage --max 10` — prioritize emails from direct reports and leadership.", + "Schedule meetings with `gws calendar +insert` — always check for conflicts first using `gws calendar +agenda`.", + "Draft replies with `gws gmail +send` — keep tone professional and concise." +] +tips = [ + "Always confirm calendar changes with the executive before committing.", + "Use `--format table` for quick visual scans of agenda and triage output.", + "Check `gws calendar +agenda --week` on Monday mornings for weekly planning." +] + +[[personas]] +name = "project-manager" +title = "Project Manager" +description = "Coordinate projects — track tasks, schedule meetings, and share docs." +services = [ "drive", "sheets", "calendar", "gmail", "chat" ] +workflows = [ "+standup-report", "+weekly-digest", "+file-announce" ] +instructions = [ + "Start the week with `gws workflow +weekly-digest` for a snapshot of upcoming meetings and unread items.", + "Track project status in Sheets using `gws sheets +append` to log updates.", + "Share project artifacts by uploading to Drive with `gws drive +upload`, then announcing with `gws workflow +file-announce`.", + "Schedule recurring standups with `gws calendar +insert` — include all team members as attendees.", + "Send status update emails to stakeholders with `gws gmail +send`." +] +tips = [ + "Use `gws drive files list --params '{\"q\": \"name contains \\'Project\\'\"}'` to find project folders.", + "Pipe triage output through `jq` for filtering by sender or subject.", + "Use `--dry-run` before any write operations to preview what will happen." +] + +[[personas]] +name = "hr-coordinator" +title = "HR Coordinator" +description = "Handle HR workflows — onboarding, announcements, and employee comms." +services = [ "gmail", "calendar", "drive", "chat" ] +workflows = [ "+email-to-task", "+file-announce" ] +instructions = [ + "For new hire onboarding, create calendar events for orientation sessions with `gws calendar +insert`.", + "Upload onboarding docs to a shared Drive folder with `gws drive +upload`.", + "Announce new hires in Chat spaces with `gws workflow +file-announce` to share their profile doc.", + "Convert email requests into tracked tasks with `gws workflow +email-to-task`.", + "Send bulk announcements with `gws gmail +send` — use clear subject lines." +] +tips = [ + "Always use `--sanitize` for PII-sensitive operations.", + "Create a dedicated 'HR Onboarding' calendar for tracking orientation schedules." +] + +[[personas]] +name = "sales-ops" +title = "Sales Operations" +description = "Manage sales workflows — track deals, schedule calls, client comms." +services = [ "gmail", "calendar", "sheets", "drive" ] +workflows = [ "+meeting-prep", "+email-to-task", "+weekly-digest" ] +instructions = [ + "Prepare for client calls with `gws workflow +meeting-prep` to review attendees and agenda.", + "Log deal updates in a tracking spreadsheet with `gws sheets +append`.", + "Convert follow-up emails into tasks with `gws workflow +email-to-task`.", + "Share proposals by uploading to Drive with `gws drive +upload`.", + "Get a weekly sales pipeline summary with `gws workflow +weekly-digest`." +] +tips = [ + "Use `gws gmail +triage --query 'from:client-domain.com'` to filter client emails.", + "Schedule follow-up calls immediately after meetings to maintain momentum.", + "Keep all client-facing documents in a dedicated shared Drive folder." +] + +[[personas]] +name = "it-admin" +title = "IT Administrator" +description = "Administer IT — monitor security and configure Workspace." +services = [ "gmail", "drive", "calendar" ] +workflows = [ "+standup-report" ] +instructions = [ + "Start the day with `gws workflow +standup-report` to review any pending IT requests.", + "Monitor suspicious login activity and review audit logs.", + "Configure Drive sharing policies to enforce organizational security." +] +tips = [ + "Always use `--dry-run` before bulk operations.", + "Review `gws auth status` regularly to verify service account permissions." +] + +[[personas]] +name = "content-creator" +title = "Content Creator" +description = "Create, organize, and distribute content across Workspace." +services = [ "docs", "drive", "gmail", "chat", "slides" ] +workflows = [ "+file-announce" ] +instructions = [ + "Draft content in Google Docs with `gws docs +write`.", + "Organize content assets in Drive folders — use `gws drive files list` to browse.", + "Share finished content by announcing in Chat with `gws workflow +file-announce`.", + "Send content review requests via email with `gws gmail +send`.", + "Upload media assets to Drive with `gws drive +upload`." +] +tips = [ + "Use `gws docs +write` for quick content updates — it handles the Docs API formatting.", + "Keep a 'Content Calendar' in a shared Sheet for tracking publication schedules.", + "Use `--format yaml` for human-readable output when debugging API responses." +] + +[[personas]] +name = "customer-support" +title = "Customer Support Agent" +description = "Manage customer support — track tickets, respond, escalate issues." +services = [ "gmail", "sheets", "chat", "calendar" ] +workflows = [ "+email-to-task", "+standup-report" ] +instructions = [ + "Triage the support inbox with `gws gmail +triage --query 'label:support'`.", + "Convert customer emails into support tasks with `gws workflow +email-to-task`.", + "Log ticket status updates in a tracking sheet with `gws sheets +append`.", + "Escalate urgent issues to the team Chat space.", + "Schedule follow-up calls with customers using `gws calendar +insert`." +] +tips = [ + "Use `gws gmail +triage --labels` to see email categories at a glance.", + "Set up Gmail filters for auto-labeling support requests.", + "Use `--format table` for quick status dashboard views." +] + +[[personas]] +name = "event-coordinator" +title = "Event Coordinator" +description = "Plan and manage events — scheduling, invitations, and logistics." +services = [ "calendar", "gmail", "drive", "chat", "sheets" ] +workflows = [ "+meeting-prep", "+file-announce", "+weekly-digest" ] +instructions = [ + "Create event calendar entries with `gws calendar +insert` — include location and attendee lists.", + "Prepare event materials and upload to Drive with `gws drive +upload`.", + "Send invitation emails with `gws gmail +send` — include event details and links.", + "Announce updates in Chat spaces with `gws workflow +file-announce`.", + "Track RSVPs and logistics in Sheets with `gws sheets +append`." +] +tips = [ + "Use `gws calendar +agenda --days 30` for long-range event planning.", + "Create a dedicated calendar for each major event series.", + "Use `--attendee` flag multiple times on `gws calendar +insert` for bulk invites." +] + +[[personas]] +name = "team-lead" +title = "Team Lead" +description = "Lead a team — run standups, coordinate tasks, and communicate." +services = [ "calendar", "gmail", "chat", "drive", "sheets" ] +workflows = [ + "+standup-report", + "+meeting-prep", + "+weekly-digest", + "+email-to-task" +] +instructions = [ + "Run daily standups with `gws workflow +standup-report` — share output in team Chat.", + "Prepare for 1:1s with `gws workflow +meeting-prep`.", + "Get weekly snapshots with `gws workflow +weekly-digest`.", + "Delegate email action items with `gws workflow +email-to-task`.", + "Track team OKRs in a shared Sheet with `gws sheets +append`." +] +tips = [ + "Use `gws calendar +agenda --week --format table` for weekly team calendar views.", + "Pipe standup reports to Chat with `gws chat spaces messages create`.", + "Use `--sanitize` for any operations involving sensitive team data." +] + +[[personas]] +name = "researcher" +title = "Researcher" +description = "Organize research — manage references, notes, and collaboration." +services = [ "drive", "docs", "sheets", "gmail" ] +workflows = [ "+file-announce" ] +instructions = [ + "Organize research papers and notes in Drive folders.", + "Write research notes and summaries with `gws docs +write`.", + "Track research data in Sheets — use `gws sheets +append` for data logging.", + "Share findings with collaborators via `gws workflow +file-announce`.", + "Request peer reviews via `gws gmail +send`." +] +tips = [ + "Use `gws drive files list` with search queries to find specific documents.", + "Keep a running log of experiments and findings in a shared Sheet.", + "Use `--format csv` when exporting data for analysis tools." +] diff --git a/crates/google-workspace-cli/registry/recipes.toml b/crates/google-workspace-cli/registry/recipes.toml new file mode 100644 index 00000000..8440ca1b --- /dev/null +++ b/crates/google-workspace-cli/registry/recipes.toml @@ -0,0 +1,496 @@ +[[recipes]] +name = "label-and-archive-emails" +title = "Label and Archive Gmail Threads" +description = "Apply Gmail labels to matching messages and archive them to keep your inbox clean." +category = "productivity" +services = [ "gmail" ] +steps = [ + "Search for matching emails: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"from:notifications@service.com\"}' --format table`", + "Apply a label: `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"addLabelIds\": [\"LABEL_ID\"]}'`", + "Archive (remove from inbox): `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"removeLabelIds\": [\"INBOX\"]}'`" +] + +[[recipes]] +name = "draft-email-from-doc" +title = "Draft a Gmail Message from a Google Doc" +description = "Read content from a Google Doc and use it as the body of a Gmail message." +category = "productivity" +services = [ "docs", "gmail" ] +steps = [ + "Get the document content: `gws docs documents get --params '{\"documentId\": \"DOC_ID\"}'`", + "Copy the text from the body content", + "Send the email: `gws gmail +send --to recipient@example.com --subject 'Newsletter Update' --body 'CONTENT_FROM_DOC'`" +] + +[[recipes]] +name = "organize-drive-folder" +title = "Organize Files into Google Drive Folders" +description = "Create a Google Drive folder structure and move files into the right locations." +category = "productivity" +services = [ "drive" ] +steps = [ + "Create a project folder: `gws drive files create --json '{\"name\": \"Q2 Project\", \"mimeType\": \"application/vnd.google-apps.folder\"}'`", + "Create sub-folders: `gws drive files create --json '{\"name\": \"Documents\", \"mimeType\": \"application/vnd.google-apps.folder\", \"parents\": [\"PARENT_FOLDER_ID\"]}'`", + "Move existing files into folder: `gws drive files update --params '{\"fileId\": \"FILE_ID\", \"addParents\": \"FOLDER_ID\", \"removeParents\": \"OLD_PARENT_ID\"}'`", + "Verify structure: `gws drive files list --params '{\"q\": \"FOLDER_ID in parents\"}' --format table`" +] + +[[recipes]] +name = "share-folder-with-team" +title = "Share a Google Drive Folder with a Team" +description = "Share a Google Drive folder and all its contents with a list of collaborators." +category = "productivity" +services = [ "drive" ] +steps = [ + "Find the folder: `gws drive files list --params '{\"q\": \"name = '\\''Project X'\\'' and mimeType = '\\''application/vnd.google-apps.folder'\\''\"}'`", + "Share as editor: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"colleague@company.com\"}'`", + "Share as viewer: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"stakeholder@company.com\"}'`", + "Verify permissions: `gws drive permissions list --params '{\"fileId\": \"FOLDER_ID\"}' --format table`" +] + +[[recipes]] +name = "email-drive-link" +title = "Email a Google Drive File Link" +description = "Share a Google Drive file and email the link with a message to recipients." +category = "productivity" +services = [ "drive", "gmail" ] +steps = [ + "Find the file: `gws drive files list --params '{\"q\": \"name = '\\''Quarterly Report'\\''\"}'`", + "Share the file: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"client@example.com\"}'`", + "Email the link: `gws gmail +send --to client@example.com --subject 'Quarterly Report' --body 'Hi, please find the report here: https://docs.google.com/document/d/FILE_ID'`" +] + +[[recipes]] +name = "create-doc-from-template" +title = "Create a Google Doc from a Template" +description = "Copy a Google Docs template, fill in content, and share with collaborators." +category = "productivity" +services = [ "drive", "docs" ] +steps = [ + "Copy the template: `gws drive files copy --params '{\"fileId\": \"TEMPLATE_DOC_ID\"}' --json '{\"name\": \"Project Brief - Q2 Launch\"}'`", + "Get the new doc ID from the response", + "Add content: `gws docs +write --document-id NEW_DOC_ID --text '## Project: Q2 Launch\n\n### Objective\nLaunch the new feature by end of Q2.'`", + "Share with team: `gws drive permissions create --params '{\"fileId\": \"NEW_DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" +] + +[[recipes]] +name = "create-expense-tracker" +title = "Create a Google Sheets Expense Tracker" +description = "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries." +category = "productivity" +services = [ "sheets", "drive" ] +steps = [ + "Create spreadsheet: `gws drive files create --json '{\"name\": \"Expense Tracker 2025\", \"mimeType\": \"application/vnd.google-apps.spreadsheet\"}'`", + "Add headers: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"Date\", \"Category\", \"Description\", \"Amount\"]'`", + "Add first entry: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"2025-01-15\", \"Travel\", \"Flight to NYC\", \"450.00\"]'`", + "Share with manager: `gws drive permissions create --params '{\"fileId\": \"SHEET_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"manager@company.com\"}'`" +] + +[[recipes]] +name = "copy-sheet-for-new-month" +title = "Copy a Google Sheet for a New Month" +description = "Duplicate a Google Sheets template tab for a new month of tracking." +category = "productivity" +services = [ "sheets" ] +steps = [ + "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`", + "Copy the template sheet: `gws sheets spreadsheets sheets copyTo --params '{\"spreadsheetId\": \"SHEET_ID\", \"sheetId\": 0}' --json '{\"destinationSpreadsheetId\": \"SHEET_ID\"}'`", + "Rename the new tab: `gws sheets spreadsheets batchUpdate --params '{\"spreadsheetId\": \"SHEET_ID\"}' --json '{\"requests\": [{\"updateSheetProperties\": {\"properties\": {\"sheetId\": 123, \"title\": \"February 2025\"}, \"fields\": \"title\"}}]}'`" +] + +[[recipes]] +name = "block-focus-time" +title = "Block Focus Time on Google Calendar" +description = "Create recurring focus time blocks on Google Calendar to protect deep work hours." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Create recurring focus block: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Focus Time\", \"description\": \"Protected deep work block\", \"start\": {\"dateTime\": \"2025-01-20T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-20T11:00:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\"], \"transparency\": \"opaque\"}'`", + "Verify it shows as busy: `gws calendar +agenda`" +] + +[[recipes]] +name = "reschedule-meeting" +title = "Reschedule a Google Calendar Meeting" +description = "Move a Google Calendar event to a new time and automatically notify all attendees." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Find the event: `gws calendar +agenda`", + "Get event details: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`", + "Update the time: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"start\": {\"dateTime\": \"2025-01-22T14:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-22T15:00:00\", \"timeZone\": \"America/New_York\"}}'`" +] + +[[recipes]] +name = "create-gmail-filter" +title = "Create a Gmail Filter" +description = "Create a Gmail filter to automatically label, star, or categorize incoming messages." +category = "productivity" +services = [ "gmail" ] +steps = [ + "List existing labels: `gws gmail users labels list --params '{\"userId\": \"me\"}' --format table`", + "Create a new label: `gws gmail users labels create --params '{\"userId\": \"me\"}' --json '{\"name\": \"Receipts\"}'`", + "Create a filter: `gws gmail users settings filters create --params '{\"userId\": \"me\"}' --json '{\"criteria\": {\"from\": \"receipts@example.com\"}, \"action\": {\"addLabelIds\": [\"LABEL_ID\"], \"removeLabelIds\": [\"INBOX\"]}}'`", + "Verify filter: `gws gmail users settings filters list --params '{\"userId\": \"me\"}' --format table`" +] + +[[recipes]] +name = "schedule-recurring-event" +title = "Schedule a Recurring Meeting" +description = "Create a recurring Google Calendar event with attendees." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Create recurring event: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Weekly Standup\", \"start\": {\"dateTime\": \"2024-03-18T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2024-03-18T09:30:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO\"], \"attendees\": [{\"email\": \"team@company.com\"}]}'`", + "Verify it was created: `gws calendar +agenda --days 14 --format table`" +] + +[[recipes]] +name = "find-free-time" +title = "Find Free Time Across Calendars" +description = "Query Google Calendar free/busy status for multiple users to find a meeting slot." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Query free/busy: `gws calendar freebusy query --json '{\"timeMin\": \"2024-03-18T08:00:00Z\", \"timeMax\": \"2024-03-18T18:00:00Z\", \"items\": [{\"id\": \"user1@company.com\"}, {\"id\": \"user2@company.com\"}]}'`", + "Review the output to find overlapping free slots", + "Create event in the free slot: `gws calendar +insert --summary 'Meeting' --attendee user1@company.com --attendee user2@company.com --start '2024-03-18T14:00:00' --end '2024-03-18T14:30:00'`" +] + +[[recipes]] +name = "bulk-download-folder" +title = "Bulk Download Drive Folder" +description = "List and download all files from a Google Drive folder." +category = "productivity" +services = [ "drive" ] +steps = [ + "List files in folder: `gws drive files list --params '{\"q\": \"'\\''FOLDER_ID'\\'' in parents\"}' --format json`", + "Download each file: `gws drive files get --params '{\"fileId\": \"FILE_ID\", \"alt\": \"media\"}' -o filename.ext`", + "Export Google Docs as PDF: `gws drive files export --params '{\"fileId\": \"FILE_ID\", \"mimeType\": \"application/pdf\"}' -o document.pdf`" +] + +[[recipes]] +name = "find-large-files" +title = "Find Largest Files in Drive" +description = "Identify large Google Drive files consuming storage quota." +category = "productivity" +services = [ "drive" ] +steps = [ + "List files sorted by size: `gws drive files list --params '{\"orderBy\": \"quotaBytesUsed desc\", \"pageSize\": 20, \"fields\": \"files(id,name,size,mimeType,owners)\"}' --format table`", + "Review the output and identify files to archive or move" +] + +[[recipes]] +name = "create-shared-drive" +title = "Create and Configure a Shared Drive" +description = "Create a Google Shared Drive and add members with appropriate roles." +category = "productivity" +services = [ "drive" ] +steps = [ + "Create shared drive: `gws drive drives create --params '{\"requestId\": \"unique-id-123\"}' --json '{\"name\": \"Project X\"}'`", + "Add a member: `gws drive permissions create --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"member@company.com\"}'`", + "List members: `gws drive permissions list --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}'`" +] + +[[recipes]] +name = "log-deal-update" +title = "Log Deal Update to Sheet" +description = "Append a deal status update to a Google Sheets sales tracking spreadsheet." +category = "sales" +services = [ "sheets", "drive" ] +steps = [ + "Find the tracking sheet: `gws drive files list --params '{\"q\": \"name = '\\''Sales Pipeline'\\'' and mimeType = '\\''application/vnd.google-apps.spreadsheet'\\''\"}'`", + "Read current data: `gws sheets +read --spreadsheet SHEET_ID --range \"Pipeline!A1:F\"`", + "Append new row: `gws sheets +append --spreadsheet SHEET_ID --range 'Pipeline' --values '[\"2024-03-15\", \"Acme Corp\", \"Proposal Sent\", \"50,000ドル\", \"Q2\", \"jdoe\"]'`" +] + +[[recipes]] +name = "collect-form-responses" +title = "Check Form Responses" +description = "Retrieve and review responses from a Google Form." +category = "productivity" +services = [ "forms" ] +steps = [ + "List forms: `gws forms forms list` (if you don't have the form ID)", + "Get form details: `gws forms forms get --params '{\"formId\": \"FORM_ID\"}'`", + "Get responses: `gws forms forms responses list --params '{\"formId\": \"FORM_ID\"}' --format table`" +] + +[[recipes]] +name = "post-mortem-setup" +title = "Set Up Post-Mortem" +description = "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat." +category = "engineering" +services = [ "docs", "calendar", "chat" ] +steps = [ + "Create post-mortem doc: `gws docs +write --title 'Post-Mortem: [Incident]' --body '## Summary\\n\\n## Timeline\\n\\n## Root Cause\\n\\n## Action Items'`", + "Schedule review meeting: `gws calendar +insert --summary 'Post-Mortem Review: [Incident]' --attendee team@company.com --start '2026-03-16T14:00:00' --end '2026-03-16T15:00:00'`", + "Notify in Chat: `gws chat +send --space spaces/ENG_SPACE --text '🔍 Post-mortem scheduled for [Incident].'`" +] + +[[recipes]] +name = "create-task-list" +title = "Create a Task List and Add Tasks" +description = "Set up a new Google Tasks list with initial tasks." +category = "productivity" +services = [ "tasks" ] +steps = [ + "Create task list: `gws tasks tasklists insert --json '{\"title\": \"Q2 Goals\"}'`", + "Add a task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Review Q1 metrics\", \"notes\": \"Pull data from analytics dashboard\", \"due\": \"2024-04-01T00:00:00Z\"}'`", + "Add another task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Draft Q2 OKRs\"}'`", + "List tasks: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\"}' --format table`" +] + +[[recipes]] +name = "review-overdue-tasks" +title = "Review Overdue Tasks" +description = "Find Google Tasks that are past due and need attention." +category = "productivity" +services = [ "tasks" ] +steps = [ + "List task lists: `gws tasks tasklists list --format table`", + "List tasks with status: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\", \"showCompleted\": false}' --format table`", + "Review due dates and prioritize overdue items" +] + +[[recipes]] +name = "watch-drive-changes" +title = "Watch for Drive Changes" +description = "Subscribe to change notifications on a Google Drive file or folder." +category = "engineering" +services = [ "events" ] +steps = [ + "Create subscription: `gws events subscriptions create --json '{\"targetResource\": \"//drive.googleapis.com/drives/DRIVE_ID\", \"eventTypes\": [\"google.workspace.drive.file.v1.updated\"], \"notificationEndpoint\": {\"pubsubTopic\": \"projects/PROJECT/topics/TOPIC\"}, \"payloadOptions\": {\"includeResource\": true}}'`", + "List active subscriptions: `gws events subscriptions list`", + "Renew before expiry: `gws events +renew --subscription SUBSCRIPTION_ID`" +] + +[[recipes]] +name = "create-classroom-course" +title = "Create a Google Classroom Course" +description = "Create a Google Classroom course and invite students." +category = "education" +services = [ "classroom" ] +steps = [ + "Create the course: `gws classroom courses create --json '{\"name\": \"Introduction to CS\", \"section\": \"Period 1\", \"room\": \"Room 101\", \"ownerId\": \"me\"}'`", + "Invite a student: `gws classroom invitations create --json '{\"courseId\": \"COURSE_ID\", \"userId\": \"student@school.edu\", \"role\": \"STUDENT\"}'`", + "List enrolled students: `gws classroom courses students list --params '{\"courseId\": \"COURSE_ID\"}' --format table`" +] + +[[recipes]] +name = "create-meet-space" +title = "Create a Google Meet Conference" +description = "Create a Google Meet meeting space and share the join link." +category = "scheduling" +services = [ "meet", "gmail" ] +steps = [ + "Create meeting space: `gws meet spaces create --json '{\"config\": {\"accessType\": \"OPEN\"}}'`", + "Copy the meeting URI from the response", + "Email the link: `gws gmail +send --to team@company.com --subject 'Join the meeting' --body 'Join here: MEETING_URI'`" +] + +[[recipes]] +name = "review-meet-participants" +title = "Review Google Meet Attendance" +description = "Review who attended a Google Meet conference and for how long." +category = "productivity" +services = [ "meet" ] +steps = [ + "List recent conferences: `gws meet conferenceRecords list --format table`", + "List participants: `gws meet conferenceRecords participants list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID\"}' --format table`", + "Get session details: `gws meet conferenceRecords participants participantSessions list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID/participants/PARTICIPANT_ID\"}' --format table`" +] + +[[recipes]] +name = "create-presentation" +title = "Create a Google Slides Presentation" +description = "Create a new Google Slides presentation and add initial slides." +category = "productivity" +services = [ "slides" ] +steps = [ + "Create presentation: `gws slides presentations create --json '{\"title\": \"Quarterly Review Q2\"}'`", + "Get the presentation ID from the response", + "Share with team: `gws drive permissions create --params '{\"fileId\": \"PRESENTATION_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" +] + +[[recipes]] +name = "save-email-attachments" +title = "Save Gmail Attachments to Google Drive" +description = "Find Gmail messages with attachments and save them to a Google Drive folder." +category = "productivity" +services = [ "gmail", "drive" ] +steps = [ + "Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`", + "Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`", + "Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`", + "Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`" +] + +[[recipes]] +name = "send-team-announcement" +title = "Announce via Gmail and Google Chat" +description = "Send a team announcement via both Gmail and a Google Chat space." +category = "communication" +services = [ "gmail", "chat" ] +steps = [ + "Send email: `gws gmail +send --to team@company.com --subject 'Important Update' --body 'Please review the attached policy changes.'`", + "Post in Chat: `gws chat +send --space spaces/TEAM_SPACE --text '📢 Important Update: Please check your email for policy changes.'`" +] + +[[recipes]] +name = "create-feedback-form" +title = "Create and Share a Google Form" +description = "Create a Google Form for feedback and share it via Gmail." +category = "productivity" +services = [ "forms", "gmail" ] +steps = [ + "Create form: `gws forms forms create --json '{\"info\": {\"title\": \"Event Feedback\", \"documentTitle\": \"Event Feedback Form\"}}'`", + "Get the form URL from the response (responderUri field)", + "Email the form: `gws gmail +send --to attendees@company.com --subject 'Please share your feedback' --body 'Fill out the form: FORM_URL'`" +] + +[[recipes]] +name = "sync-contacts-to-sheet" +title = "Export Google Contacts to Sheets" +description = "Export Google Contacts directory to a Google Sheets spreadsheet." +category = "productivity" +services = [ "people", "sheets" ] +steps = [ + "List contacts: `gws people people listDirectoryPeople --params '{\"readMask\": \"names,emailAddresses,phoneNumbers\", \"sources\": [\"DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE\"], \"pageSize\": 100}' --format json`", + "Create a sheet: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Name\", \"Email\", \"Phone\"]'`", + "Append each contact row: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Jane Doe\", \"jane@company.com\", \"+1-555-0100\"]'`" +] + +[[recipes]] +name = "share-event-materials" +title = "Share Files with Meeting Attendees" +description = "Share Google Drive files with all attendees of a Google Calendar event." +category = "productivity" +services = [ "calendar", "drive" ] +steps = [ + "Get event attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`", + "Share file with each attendee: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"attendee@company.com\"}'`", + "Verify sharing: `gws drive permissions list --params '{\"fileId\": \"FILE_ID\"}' --format table`" +] + +[[recipes]] +name = "create-vacation-responder" +title = "Set Up a Gmail Vacation Responder" +description = "Enable a Gmail out-of-office auto-reply with a custom message and date range." +category = "productivity" +services = [ "gmail" ] +steps = [ + "Enable vacation responder: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": true, \"responseSubject\": \"Out of Office\", \"responseBodyPlainText\": \"I am out of the office until Jan 20. For urgent matters, contact backup@company.com.\", \"restrictToContacts\": false, \"restrictToDomain\": false}'`", + "Verify settings: `gws gmail users settings getVacation --params '{\"userId\": \"me\"}'`", + "Disable when back: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": false}'`" +] + +[[recipes]] +name = "create-events-from-sheet" +title = "Create Google Calendar Events from a Sheet" +description = "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row." +category = "productivity" +services = [ "sheets", "calendar" ] +steps = [ + "Read event data: `gws sheets +read --spreadsheet SHEET_ID --range \"Events!A2:D\"`", + "For each row, create a calendar event: `gws calendar +insert --summary 'Team Standup' --start '2026-01-20T09:00:00' --end '2026-01-20T09:30:00' --attendee alice@company.com --attendee bob@company.com`" +] + +[[recipes]] +name = "plan-weekly-schedule" +title = "Plan Your Weekly Google Calendar Schedule" +description = "Review your Google Calendar week, identify gaps, and add events to fill them." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Check this week's agenda: `gws calendar +agenda`", + "Check free/busy for the week: `gws calendar freebusy query --json '{\"timeMin\": \"2025-01-20T00:00:00Z\", \"timeMax\": \"2025-01-25T00:00:00Z\", \"items\": [{\"id\": \"primary\"}]}'`", + "Add a new event: `gws calendar +insert --summary 'Deep Work Block' --start '2026-01-21T14:00:00' --end '2026-01-21T16:00:00'`", + "Review updated schedule: `gws calendar +agenda`" +] + +[[recipes]] +name = "share-doc-and-notify" +title = "Share a Google Doc and Notify Collaborators" +description = "Share a Google Docs document with edit access and email collaborators the link." +category = "productivity" +services = [ "drive", "docs", "gmail" ] +steps = [ + "Find the doc: `gws drive files list --params '{\"q\": \"name contains '\\''Project Brief'\\'' and mimeType = '\\''application/vnd.google-apps.document'\\''\"}'`", + "Share with editor access: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"reviewer@company.com\"}'`", + "Email the link: `gws gmail +send --to reviewer@company.com --subject 'Please review: Project Brief' --body 'I have shared the project brief with you: https://docs.google.com/document/d/DOC_ID'`" +] + +[[recipes]] +name = "backup-sheet-as-csv" +title = "Export a Google Sheet as CSV" +description = "Export a Google Sheets spreadsheet as a CSV file for local backup or processing." +category = "productivity" +services = [ "sheets", "drive" ] +steps = [ + "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`", + "Export as CSV: `gws drive files export --params '{\"fileId\": \"SHEET_ID\", \"mimeType\": \"text/csv\"}'`", + "Or read values directly: `gws sheets +read --spreadsheet SHEET_ID --range 'Sheet1' --format csv`" +] + +[[recipes]] +name = "save-email-to-doc" +title = "Save a Gmail Message to Google Docs" +description = "Save a Gmail message body into a Google Doc for archival or reference." +category = "productivity" +services = [ "gmail", "docs" ] +steps = [ + "Find the message: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"subject:important from:boss@company.com\"}' --format table`", + "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`", + "Create a doc with the content: `gws docs documents create --json '{\"title\": \"Saved Email - Important Update\"}'`", + "Write the email body: `gws docs +write --document-id DOC_ID --text 'From: boss@company.com\nSubject: Important Update\n\n[EMAIL BODY]'`" +] + +[[recipes]] +name = "compare-sheet-tabs" +title = "Compare Two Google Sheets Tabs" +description = "Read data from two tabs in a Google Sheet to compare and identify differences." +category = "productivity" +services = [ "sheets" ] +steps = [ + "Read the first tab: `gws sheets +read --spreadsheet SHEET_ID --range \"January!A1:D\"`", + "Read the second tab: `gws sheets +read --spreadsheet SHEET_ID --range \"February!A1:D\"`", + "Compare the data and identify changes" +] + +[[recipes]] +name = "batch-invite-to-event" +title = "Add Multiple Attendees to a Calendar Event" +description = "Add a list of attendees to an existing Google Calendar event and send notifications." +category = "scheduling" +services = [ "calendar" ] +steps = [ + "Get the event: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`", + "Add attendees: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"attendees\": [{\"email\": \"alice@company.com\"}, {\"email\": \"bob@company.com\"}, {\"email\": \"carol@company.com\"}]}'`", + "Verify attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" +] + +[[recipes]] +name = "forward-labeled-emails" +title = "Forward Labeled Gmail Messages" +description = "Find Gmail messages with a specific label and forward them to another address." +category = "productivity" +services = [ "gmail" ] +steps = [ + "Find labeled messages: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"label:needs-review\"}' --format table`", + "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`", + "Forward via new email: `gws gmail +send --to manager@company.com --subject 'FW: [Original Subject]' --body 'Forwarding for your review:\n\n[Original Message Body]'`" +] + +[[recipes]] +name = "generate-report-from-sheet" +title = "Generate a Google Docs Report from Sheet Data" +description = "Read data from a Google Sheet and create a formatted Google Docs report." +category = "productivity" +services = [ "sheets", "docs", "drive" ] +steps = [ + "Read the data: `gws sheets +read --spreadsheet SHEET_ID --range \"Sales!A1:D\"`", + "Create the report doc: `gws docs documents create --json '{\"title\": \"Sales Report - January 2025\"}'`", + "Write the report: `gws docs +write --document-id DOC_ID --text '## Sales Report - January 2025\n\n### Summary\nTotal deals: 45\nRevenue: 125,000ドル\n\n### Top Deals\n1. Acme Corp - 25,000ドル\n2. Widget Inc - 18,000ドル'`", + "Share with stakeholders: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"cfo@company.com\"}'`" +] diff --git a/src/auth.rs b/crates/google-workspace-cli/src/auth.rs similarity index 89% rename from src/auth.rs rename to crates/google-workspace-cli/src/auth.rs index b602d840..9d8847e4 100644 --- a/src/auth.rs +++ b/crates/google-workspace-cli/src/auth.rs @@ -21,9 +21,65 @@ use std::path::PathBuf; use anyhow::Context; +use serde::Deserialize; use crate::credential_store; +const PROXY_ENV_VARS: &[&str] = &[ + "http_proxy", + "HTTP_PROXY", + "https_proxy", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", +]; + +/// Response from Google's token endpoint +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: String, + #[allow(dead_code)] + expires_in: u64, + #[allow(dead_code)] + token_type: String, +} + +/// Refresh an access token using reqwest (supports HTTP proxy via environment variables). +/// This is used as a fallback when yup-oauth2's hyper-based client fails due to proxy issues. +async fn refresh_token_with_reqwest( + client_id: &str, + client_secret: &str, + refresh_token: &str, +) -> anyhow::Result { + let client = crate::client::shared_client().map_err(anyhow::Error::from)?; + let params = [ + ("client_id", client_id), + ("client_secret", client_secret), + ("refresh_token", refresh_token), + ("grant_type", "refresh_token"), + ]; + + let response = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .context("Failed to send token refresh request")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response_text_or_placeholder(response.text().await); + anyhow::bail!("Token refresh failed with status {}: {}", status, body); + } + + let token_response: TokenResponse = response + .json() + .await + .context("Failed to parse token response")?; + + Ok(token_response.access_token) +} + /// Returns the project ID to be used for quota and billing (sets the `x-goog-user-project` header). /// /// Priority: @@ -173,14 +229,37 @@ pub async fn get_token(scopes: &[&str]) -> anyhow::Result { get_token_inner(scopes, creds, &token_cache).await } +/// Check if HTTP proxy environment variables are set +pub(crate) fn has_proxy_env() -> bool { + PROXY_ENV_VARS + .iter() + .any(|key| std::env::var_os(key).is_some_and(|value| !value.is_empty())) +} + +pub(crate) fn response_text_or_placeholder(result: Result) -> String { + result.unwrap_or_else(|_| "(could not read error response body)".to_string()) +} + async fn get_token_inner( scopes: &[&str], creds: Credential, token_cache_path: &std::path::Path, ) -> anyhow::Result { match creds { - Credential::AuthorizedUser(secret) => { - let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret) + Credential::AuthorizedUser(ref secret) => { + // If proxy env vars are set, use reqwest directly (it supports proxy) + // This avoids waiting for yup-oauth2's hyper client to timeout + if has_proxy_env() { + return refresh_token_with_reqwest( + &secret.client_id, + &secret.client_secret, + &secret.refresh_token, + ) + .await; + } + + // No proxy - use yup-oauth2 (faster, has token caching) + let auth = yup_oauth2::AuthorizedUserAuthenticator::builder(secret.clone()) .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( token_cache_path.to_path_buf(), ))) @@ -398,6 +477,43 @@ mod tests { } } + fn clear_proxy_env() -> Vec { + PROXY_ENV_VARS + .iter() + .map(|key| EnvVarGuard::remove(key)) + .collect() + } + + #[test] + #[serial_test::serial] + fn has_proxy_env_returns_false_when_unset() { + let _guards = clear_proxy_env(); + assert!(!has_proxy_env()); + } + + #[test] + #[serial_test::serial] + fn has_proxy_env_returns_true_when_set() { + let mut guards = clear_proxy_env(); + guards.push(EnvVarGuard::set( + "HTTPS_PROXY", + "http://proxy.internal:8080", + )); + assert!(has_proxy_env()); + } + + #[test] + fn response_text_or_placeholder_returns_body() { + let body = response_text_or_placeholder(Result::::Ok("error body".to_string())); + assert_eq!(body, "error body"); + } + + #[test] + fn response_text_or_placeholder_returns_placeholder_on_error() { + let body = response_text_or_placeholder(Result::::Err(())); + assert_eq!(body, "(could not read error response body)"); + } + #[tokio::test] #[serial_test::serial] async fn test_load_credentials_no_options() { @@ -634,6 +750,7 @@ mod tests { } #[tokio::test] + #[serial_test::serial] async fn test_load_credentials_encrypted_file() { // Simulate an encrypted credentials file let json = r#"{ @@ -646,6 +763,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let enc_path = dir.path().join("credentials.enc"); + // Isolate global config dir to prevent races with other tests + std::env::set_var("GOOGLE_WORKSPACE_CLI_CONFIG_DIR", dir.path()); + // Encrypt and write let encrypted = crate::credential_store::encrypt(json.as_bytes()).unwrap(); std::fs::write(&enc_path, &encrypted).unwrap(); diff --git a/src/auth_commands.rs b/crates/google-workspace-cli/src/auth_commands.rs similarity index 73% rename from src/auth_commands.rs rename to crates/google-workspace-cli/src/auth_commands.rs index f51ba6dd..d7571e74 100644 --- a/src/auth_commands.rs +++ b/crates/google-workspace-cli/src/auth_commands.rs @@ -13,26 +13,243 @@ // limitations under the License. use std::collections::HashSet; -use std::path::PathBuf; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use serde::Deserialize; use serde_json::json; use crate::credential_store; use crate::error::GwsError; +/// Response from Google's token endpoint +#[derive(Debug, Deserialize)] +struct OAuthTokenResponse { + access_token: String, + refresh_token: Option, + #[allow(dead_code)] + expires_in: u64, + #[allow(dead_code)] + token_type: String, +} + +/// Exchange authorization code for tokens using reqwest (supports HTTP proxy) +async fn exchange_code_with_reqwest( + client_id: &str, + client_secret: &str, + code: &str, + redirect_uri: &str, +) -> Result { + let client = crate::client::shared_client()?; + let params = [ + ("client_id", client_id), + ("client_secret", client_secret), + ("code", code), + ("redirect_uri", redirect_uri), + ("grant_type", "authorization_code"), + ]; + + let response = client + .post("https://oauth2.googleapis.com/token") + .form(¶ms) + .send() + .await + .map_err(|e| GwsError::Auth(format!("Failed to send token request: {e}")))?; + + if !response.status().is_success() { + let status = response.status(); + let body = crate::auth::response_text_or_placeholder(response.text().await); + return Err(GwsError::Auth(format!( + "Token exchange failed with status {}: {}", + status, body + ))); + } + + response + .json() + .await + .map_err(|e| GwsError::Auth(format!("Failed to parse token response: {e}"))) +} + +fn build_proxy_auth_url(client_id: &str, redirect_uri: &str, scopes: &[String]) -> String { + let scopes_str = scopes.join(" "); + format!( + "https://accounts.google.com/o/oauth2/auth?\ + scope={}&\ + access_type=offline&\ + redirect_uri={}&\ + response_type=code&\ + client_id={}&\ + prompt=select_account+consent", + urlencoding(&scopes_str), + urlencoding(redirect_uri), + urlencoding(client_id) + ) +} + +fn extract_authorization_code(request_line: &str) -> Result { + let path = request_line + .split_whitespace() + .nth(1) + .ok_or_else(|| GwsError::Auth("Invalid HTTP request".to_string()))?; + + path.split('?') + .nth(1) + .and_then(|query| { + query.split('&').find_map(|pair| { + let mut parts = pair.split('='); + if parts.next() == Some("code") { + parts.next().map(|value| value.to_string()) + } else { + None + } + }) + }) + .ok_or_else(|| GwsError::Auth("No authorization code in callback".to_string())) +} + +/// Perform OAuth login flow with proxy support using reqwest for token exchange +async fn login_with_proxy_support( + client_id: &str, + client_secret: &str, + scopes: &[String], +) -> Result<(string, String), GwsError> { + // Start local server to receive OAuth callback + let listener = TcpListener::bind("127.0.0.1:0") + .map_err(|e| GwsError::Auth(format!("Failed to start local server: {e}")))?; + let port = listener + .local_addr() + .map_err(|e| GwsError::Auth(format!("Failed to inspect local server: {e}")))? + .port(); + let redirect_uri = format!("http://localhost:{}", port); + + let auth_url = build_proxy_auth_url(client_id, &redirect_uri, scopes); + + println!("Open this URL in your browser to authenticate:\n"); + println!(" {}\n", auth_url); + + // Wait for OAuth callback + let (mut stream, _) = listener + .accept() + .map_err(|e| GwsError::Auth(format!("Failed to accept connection: {e}")))?; + + let mut reader = BufReader::new(&stream); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .map_err(|e| GwsError::Auth(format!("Failed to read request: {e}")))?; + + let code = extract_authorization_code(&request_line)?; + + // Send success response to browser + let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n\ +

Success!

You may now close this window.

AltStyle によって変換されたページ (->オリジナル) /

"; + let _ = stream.write_all(response.as_bytes()); + + // Exchange code for tokens using reqwest (proxy-aware) + let token_response = + exchange_code_with_reqwest(client_id, client_secret, &code, &redirect_uri).await?; + + let refresh_token = token_response.refresh_token.ok_or_else(|| { + GwsError::Auth( + "OAuth flow completed but no refresh token was returned. \ + Ensure the OAuth consent screen includes 'offline' access." + .to_string(), + ) + })?; + + Ok((token_response.access_token, refresh_token)) +} + +fn read_refresh_token_from_cache(temp_path: &Path) -> Result { + let token_data = std::fs::read(temp_path) + .ok() + .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok()) + .and_then(|decrypted| String::from_utf8(decrypted).ok()) + .unwrap_or_default(); + + extract_refresh_token(&token_data).ok_or_else(|| { + GwsError::Auth( + "OAuth flow completed but no refresh token was returned. \ + Ensure the OAuth consent screen includes 'offline' access." + .to_string(), + ) + }) +} + +async fn login_with_yup_oauth( + config_dir: &Path, + client_id: &str, + client_secret: &str, + scopes: &[String], +) -> Result<(string, String), GwsError> { + let secret = yup_oauth2::ApplicationSecret { + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), + token_uri: "https://oauth2.googleapis.com/token".to_string(), + redirect_uris: vec!["http://localhost".to_string()], + ..Default::default() + }; + + let temp_path = config_dir.join("credentials.tmp"); + let _ = std::fs::remove_file(&temp_path); + + let result = async { + let auth = yup_oauth2::InstalledFlowAuthenticator::builder( + secret, + yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect, + ) + .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( + temp_path.clone(), + ))) + .force_account_selection(true) + .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) + .build() + .await + .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; + + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + let token = auth + .token(&scope_refs) + .await + .map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?; + + let access_token = token + .token() + .ok_or_else(|| GwsError::Auth("No access token returned".to_string()))? + .to_string(); + let refresh_token = read_refresh_token_from_cache(&temp_path)?; + + Ok((access_token, refresh_token)) + } + .await; + + let _ = std::fs::remove_file(&temp_path); + result +} + +/// Simple URL encoding +fn urlencoding(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() +} + /// Mask a secret string by showing only the first 4 and last 4 characters. /// Strings with 8 or fewer characters are fully replaced with "***". +/// +/// Uses char-based indexing (not byte offsets) so multi-byte UTF-8 secrets +/// never cause a panic. fn mask_secret(s: &str) -> String { const MASK_PREFIX_LEN: usize = 4; const MASK_SUFFIX_LEN: usize = 4; const MIN_LEN_FOR_PARTIAL_MASK: usize = MASK_PREFIX_LEN + MASK_SUFFIX_LEN; - if s.len()> MIN_LEN_FOR_PARTIAL_MASK { - format!( - "{}...{}", - &s[..MASK_PREFIX_LEN], - &s[s.len() - MASK_SUFFIX_LEN..] - ) + let char_count = s.chars().count(); + if char_count> MIN_LEN_FOR_PARTIAL_MASK { + let prefix: String = s.chars().take(MASK_PREFIX_LEN).collect(); + let suffix: String = s.chars().skip(char_count - MASK_SUFFIX_LEN).collect(); + format!("{prefix}...{suffix}") } else { "***".to_string() } @@ -128,51 +345,196 @@ fn token_cache_path() -> PathBuf { config_dir().join("token_cache.json") } +/// Which scope set to use for login. +enum ScopeMode { + /// Use the default scopes (MINIMAL_SCOPES). + Default, + /// Use readonly scopes. + Readonly, + /// Use full scopes (incl. pubsub + cloud-platform). + Full, + /// Use explicitly provided custom scopes. + Custom(Vec), +} + +/// Build the clap Command for the `login` subcommand. +/// Used by both `auth_command()` and `login_command()` as single source of truth. +fn build_login_subcommand() -> clap::Command { + clap::Command::new("login") + .about("Authenticate via OAuth2 (opens browser)") + .arg( + clap::Arg::new("readonly") + .long("readonly") + .help("Request read-only scopes") + .action(clap::ArgAction::SetTrue) + .conflicts_with_all(["full", "scopes"]), + ) + .arg( + clap::Arg::new("full") + .long("full") + .help("Request all scopes incl. pubsub + cloud-platform") + .action(clap::ArgAction::SetTrue) + .conflicts_with_all(["readonly", "scopes"]), + ) + .arg( + clap::Arg::new("scopes") + .long("scopes") + .help("Comma-separated custom scopes") + .value_name("scopes") + .conflicts_with_all(["readonly", "full"]), + ) + .arg( + clap::Arg::new("services") + .short('s') + .long("services") + .help( + "Comma-separated service names to limit scope picker (e.g. drive,gmail,sheets)", + ) + .value_name("services"), + ) +} + +/// Build the clap Command for `gws auth`. +fn auth_command() -> clap::Command { + clap::Command::new("auth") + .about("Manage authentication for Google Workspace APIs") + .subcommand_required(false) + .subcommand(build_login_subcommand()) + .subcommand( + clap::Command::new("setup") + .about("Configure GCP project + OAuth client (requires gcloud)") + .disable_help_flag(true) + // setup has its own clap-based arg parsing in setup.rs, + // so we pass remaining args through. + .arg( + clap::Arg::new("args") + .trailing_var_arg(true) + .allow_hyphen_values(true) + .num_args(0..) + .value_name("ARGS"), + ), + ) + .subcommand(clap::Command::new("status").about("Show current authentication state")) + .subcommand( + clap::Command::new("export") + .about("Print decrypted credentials to stdout") + .arg( + clap::Arg::new("unmasked") + .long("unmasked") + .help("Show secrets without masking") + .action(clap::ArgAction::SetTrue), + ), + ) + .subcommand(clap::Command::new("logout").about("Clear saved credentials and token cache")) +} + /// Handle `gws auth `. pub async fn handle_auth_command(args: &[String]) -> Result<(), GwsError> { - const USAGE: &str = concat!( - "Usage: gws auth [options]\n\n", - " login Authenticate via OAuth2 (opens browser)\n", - " --readonly Request read-only scopes\n", - " --full Request all scopes incl. pubsub + cloud-platform\n", - " (may trigger restricted_client for unverified apps)\n", - " --scopes Comma-separated custom scopes\n", - " -s, --services Comma-separated service names to limit scope picker\n", - " (e.g. -s drive,gmail,sheets)\n", - " setup Configure GCP project + OAuth client (requires gcloud)\n", - " --project Use a specific GCP project\n", - " --login Run `gws auth login` after successful setup\n", - " status Show current authentication state\n", - " export Print decrypted credentials to stdout\n", - " logout Clear saved credentials and token cache", - ); + let matches = match auth_command() + .try_get_matches_from(std::iter::once("auth".to_string()).chain(args.iter().cloned())) + { + Ok(m) => m, + Err(e) + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() == clap::error::ErrorKind::DisplayVersion => + { + e.print().map_err(|io_err| { + GwsError::Validation(format!("Failed to print help: {io_err}")) + })?; + return Ok(()); + } + Err(e) => return Err(GwsError::Validation(e.to_string())), + }; - // Honour --help / -h before treating the first arg as a subcommand. - if args.is_empty() || args[0] == "--help" || args[0] == "-h" { - println!("{USAGE}"); - return Ok(()); - } + match matches.subcommand() { + Some(("login", sub_m)) => { + let (scope_mode, services_filter) = parse_login_args(sub_m); - match args[0].as_str() { - "login" => run_login(&args[1..]).await, - "setup" => crate::setup::run_setup(&args[1..]).await, - "status" => handle_status().await, - "export" => { - let unmasked = args.len()> 1 && args[1] == "--unmasked"; + handle_login_inner(scope_mode, services_filter).await + } + Some(("setup", sub_m)) => { + // Collect remaining args and delegate to setup's own clap parser. + let setup_args: Vec = sub_m + .get_many::("args") + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + crate::setup::run_setup(&setup_args).await + } + Some(("status", _)) => handle_status().await, + Some(("export", sub_m)) => { + let unmasked = sub_m.get_flag("unmasked"); handle_export(unmasked).await } - "logout" => handle_logout(), - other => Err(GwsError::Validation(format!( - "Unknown auth subcommand: '{other}'. Use: login, setup, status, export, logout" - ))), + Some(("logout", _)) => handle_logout(), + _ => { + // No subcommand → print help + auth_command() + .print_help() + .map_err(|e| GwsError::Validation(format!("Failed to print help: {e}")))?; + Ok(()) + } } } +/// Build the clap Command for `gws auth login` (used by `run_login` for +/// standalone parsing when called from setup.rs). +fn login_command() -> clap::Command { + build_login_subcommand() +} + +/// Extract `ScopeMode` and optional services filter from parsed login args. +fn parse_login_args(matches: &clap::ArgMatches) -> (ScopeMode, Option>) { + let scope_mode = if let Some(scopes_str) = matches.get_one::("scopes") { + ScopeMode::Custom( + scopes_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect(), + ) + } else if matches.get_flag("readonly") { + ScopeMode::Readonly + } else if matches.get_flag("full") { + ScopeMode::Full + } else { + ScopeMode::Default + }; + + let services_filter: Option> = matches.get_one::("services").map(|v| { + v.split(',') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect() + }); + + (scope_mode, services_filter) +} + /// Run the `auth login` flow. /// /// Exposed for internal orchestration (e.g. `auth setup --login`). +/// Accepts raw args for backward compat with setup.rs calling `run_login(&[])`. pub async fn run_login(args: &[String]) -> Result<(), GwsError> { - handle_login(args).await + let matches = match login_command() + .try_get_matches_from(std::iter::once("login".to_string()).chain(args.iter().cloned())) + { + Ok(m) => m, + Err(e) + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() == clap::error::ErrorKind::DisplayVersion => + { + e.print().map_err(|io_err| { + GwsError::Validation(format!("Failed to print help: {io_err}")) + })?; + return Ok(()); + } + Err(e) => return Err(GwsError::Validation(e.to_string())), + }; + + let (scope_mode, services_filter) = parse_login_args(&matches); + + handle_login_inner(scope_mode, services_filter).await } /// Custom delegate that prints the OAuth URL on its own line for easy copying. /// Optionally includes `login_hint` in the URL for account pre-selection. @@ -210,36 +572,11 @@ impl yup_oauth2::authenticator_delegate::InstalledFlowDelegate for CliFlowDelega } } -async fn handle_login(args: &[String]) -> Result<(), GwsError> { - // Extract -s/--services from args - let mut services_filter: Option> = None; - let mut filtered_args: Vec = Vec::new(); - let mut skip_next = false; - for i in 0..args.len() { - if skip_next { - skip_next = false; - continue; - } - let services_str = if (args[i] == "-s" || args[i] == "--services") && i + 1 < args.len() { - skip_next = true; - Some(args[i + 1].as_str()) - } else { - args[i].strip_prefix("--services=") - }; - - if let Some(value) = services_str { - services_filter = Some( - value - .split(',') - .map(|s| s.trim().to_lowercase()) - .filter(|s| !s.is_empty()) - .collect(), - ); - continue; - } - filtered_args.push(args[i].clone()); - } - +/// Inner login implementation that takes already-parsed options. +async fn handle_login_inner( + scope_mode: ScopeMode, + services_filter: Option>, +) -> Result<(), GwsError> { // Resolve client_id and client_secret: // 1. Env vars (highest priority) // 2. Saved client_secret.json from `gws auth setup` or manual download @@ -256,124 +593,70 @@ async fn handle_login(args: &[String]) -> Result<(), GwsError> { } // Determine scopes: explicit flags> interactive TUI> defaults - let scopes = resolve_scopes( - &filtered_args, - project_id.as_deref(), - services_filter.as_ref(), - ) - .await; + let scopes = resolve_scopes(scope_mode, project_id.as_deref(), services_filter.as_ref()).await; // Remove restrictive scopes when broader alternatives are present. let mut scopes = filter_redundant_restrictive_scopes(scopes); - let secret = yup_oauth2::ApplicationSecret { - client_id: client_id.clone(), - client_secret: client_secret.clone(), - auth_uri: "https://accounts.google.com/o/oauth2/auth".to_string(), - token_uri: "https://oauth2.googleapis.com/token".to_string(), - redirect_uris: vec!["http://localhost".to_string()], - ..Default::default() - }; - - // Ensure openid + email scopes are always present so we can identify the user - // via the userinfo endpoint after login. - let identity_scopes = ["openid", "https://www.googleapis.com/auth/userinfo.email"]; + // Ensure openid + email + profile scopes are always present so we can + // identify the user via the userinfo endpoint after login, and so the + // Gmail helpers can fall back to the People API to populate the From + // display name when the send-as identity lacks one (Workspace accounts). + let identity_scopes = [ + "openid", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ]; for s in &identity_scopes { if !scopes.iter().any(|existing| existing == s) { scopes.push(s.to_string()); } } - // Use a temp file for yup-oauth2's token persistence, then encrypt it - let temp_path = config_dir().join("credentials.tmp"); - - // Always start fresh — delete any stale temp cache from prior login attempts. - let _ = std::fs::remove_file(&temp_path); - // Ensure config directory exists - if let Some(parent) = temp_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; - } - - let auth = yup_oauth2::InstalledFlowAuthenticator::builder( - secret, - yup_oauth2::InstalledFlowReturnMethod::HTTPRedirect, - ) - .with_storage(Box::new(crate::token_storage::EncryptedTokenStorage::new( - temp_path.clone(), - ))) - .force_account_selection(true) // Adds prompt=consent so Google always returns a refresh_token - .flow_delegate(Box::new(CliFlowDelegate { login_hint: None })) - .build() - .await - .map_err(|e| GwsError::Auth(format!("Failed to build authenticator: {e}")))?; - - // Request a token — this triggers the browser OAuth flow - let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); - let token = auth - .token(&scope_refs) - .await - .map_err(|e| GwsError::Auth(format!("OAuth flow failed: {e}")))?; - - if token.token().is_some() { - // Read yup-oauth2's token cache to extract the refresh_token. - // EncryptedTokenStorage stores data encrypted, so we must decrypt first. - let token_data = std::fs::read(&temp_path) - .ok() - .and_then(|bytes| crate::credential_store::decrypt(&bytes).ok()) - .and_then(|decrypted| String::from_utf8(decrypted).ok()) - .unwrap_or_default(); - let refresh_token = extract_refresh_token(&token_data).ok_or_else(|| { - GwsError::Auth( - "OAuth flow completed but no refresh token was returned. \ - Ensure the OAuth consent screen includes 'offline' access." - .to_string(), - ) - })?; - - // Build credentials in the standard authorized_user format - let creds_json = json!({ - "type": "authorized_user", - "client_id": client_id, - "client_secret": client_secret, - "refresh_token": refresh_token, - }); + let config = config_dir(); + std::fs::create_dir_all(&config) + .map_err(|e| GwsError::Validation(format!("Failed to create config directory: {e}")))?; + + // If proxy env vars are set, use proxy-aware OAuth flow (reqwest) + // Otherwise use yup-oauth2 (faster, but doesn't support proxy) + let (access_token, refresh_token) = if crate::auth::has_proxy_env() { + login_with_proxy_support(&client_id, &client_secret, &scopes).await? + } else { + login_with_yup_oauth(&config, &client_id, &client_secret, &scopes).await? + }; - let creds_str = serde_json::to_string_pretty(&creds_json) - .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?; + // Build credentials in the standard authorized_user format + let creds_json = json!({ + "type": "authorized_user", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + }); - // Fetch the user's email from Google userinfo - let access_token = token.token().unwrap_or_default(); - let actual_email = fetch_userinfo_email(access_token).await; + let creds_str = serde_json::to_string_pretty(&creds_json) + .map_err(|e| GwsError::Validation(format!("Failed to serialize credentials: {e}")))?; - // Save encrypted credentials - let enc_path = credential_store::save_encrypted(&creds_str) - .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; + // Fetch the user's email from Google userinfo + let actual_email = fetch_userinfo_email(&access_token).await; - // Clean up temp file - let _ = std::fs::remove_file(&temp_path); + // Save encrypted credentials + let enc_path = credential_store::save_encrypted(&creds_str) + .map_err(|e| GwsError::Auth(format!("Failed to encrypt credentials: {e}")))?; - let output = json!({ - "status": "success", - "message": "Authentication successful. Encrypted credentials saved.", - "account": actual_email.as_deref().unwrap_or("(unknown)"), - "credentials_file": enc_path.display().to_string(), - "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", - "scopes": scopes, - }); - println!( - "{}", - serde_json::to_string_pretty(&output).unwrap_or_default() - ); - Ok(()) - } else { - // Clean up temp file on failure - let _ = std::fs::remove_file(&temp_path); - Err(GwsError::Auth( - "OAuth flow completed but no token was returned.".to_string(), - )) - } + let output = json!({ + "status": "success", + "message": "Authentication successful. Encrypted credentials saved.", + "account": actual_email.as_deref().unwrap_or("(unknown)"), + "credentials_file": enc_path.display().to_string(), + "encryption": "AES-256-GCM (key in OS keyring or local `.encryption_key`; set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file for headless)", + "scopes": scopes, + }); + println!( + "{}", + serde_json::to_string_pretty(&output).unwrap_or_default() + ); + Ok(()) } /// Fetch the authenticated user's email from Google's userinfo endpoint. @@ -471,32 +754,25 @@ fn resolve_client_credentials() -> Result<(string, String, Option), GwsE /// When `services_filter` is `Some`, only scopes belonging to the specified /// services are shown in the picker (or returned in non-interactive mode). async fn resolve_scopes( - args: &[String], + scope_mode: ScopeMode, project_id: Option<&str>, services_filter: Option<&hashset>, ) -> Vec { - // Explicit --scopes flag takes priority (bypasses services filter) - for i in 0..args.len() { - if args[i] == "--scopes" && i + 1 < args.len() { - return args[i + 1] - .split(',') - .map(|s| s.trim().to_string()) - .collect(); + match scope_mode { + ScopeMode::Custom(scopes) => return scopes, + ScopeMode::Readonly => { + let scopes: Vec = READONLY_SCOPES.iter().map(|s| s.to_string()).collect(); + let mut result = filter_scopes_by_services(scopes, services_filter); + augment_with_dynamic_scopes(&mut result, services_filter, true).await; + return result; } - } - let readonly_only = args.iter().any(|a| a == "--readonly"); - - if readonly_only { - let scopes: Vec = READONLY_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(scopes, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, true).await; - return result; - } - if args.iter().any(|a| a == "--full") { - let scopes: Vec = FULL_SCOPES.iter().map(|s| s.to_string()).collect(); - let mut result = filter_scopes_by_services(scopes, services_filter); - augment_with_dynamic_scopes(&mut result, services_filter, false).await; - return result; + ScopeMode::Full => { + let scopes: Vec = FULL_SCOPES.iter().map(|s| s.to_string()).collect(); + let mut result = filter_scopes_by_services(scopes, services_filter); + augment_with_dynamic_scopes(&mut result, services_filter, false).await; + return result; + } + ScopeMode::Default => {} // fall through to interactive picker / defaults } // Interactive scope picker when running in a TTY @@ -1087,68 +1363,71 @@ async fn handle_status() -> Result<(), GwsError> { if let (Some(cid), Some(csec), Some(rt)) = (client_id, client_secret, refresh_token) { // Exchange refresh token for access token - let http_client = reqwest::Client::new(); - let token_resp = http_client - .post("https://oauth2.googleapis.com/token") - .form(&[ - ("client_id", cid), - ("client_secret", csec), - ("refresh_token", rt), - ("grant_type", "refresh_token"), - ]) - .send() - .await; - - if let Ok(resp) = token_resp { - if let Ok(token_json) = resp.json::().await { - if let Some(access_token) = - token_json.get("access_token").and_then(|v| v.as_str()) - { - output["token_valid"] = json!(true); - - // Get user info - if let Ok(user_resp) = http_client - .get("https://www.googleapis.com/oauth2/v1/userinfo") - .bearer_auth(access_token) - .send() - .await + if let Ok(http_client) = crate::client::shared_client() { + let token_resp = http_client + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("client_id", cid), + ("client_secret", csec), + ("refresh_token", rt), + ("grant_type", "refresh_token"), + ]) + .send() + .await; + + if let Ok(resp) = token_resp { + if let Ok(token_json) = resp.json::().await { + if let Some(access_token) = + token_json.get("access_token").and_then(|v| v.as_str()) { - if let Ok(user_json) = - user_resp.json::().await + output["token_valid"] = json!(true); + + // Get user info + if let Ok(user_resp) = http_client + .get("https://www.googleapis.com/oauth2/v1/userinfo") + .bearer_auth(access_token) + .send() + .await { - if let Some(email) = - user_json.get("email").and_then(|v| v.as_str()) + if let Ok(user_json) = + user_resp.json::().await { - output["user"] = json!(email); + if let Some(email) = + user_json.get("email").and_then(|v| v.as_str()) + { + output["user"] = json!(email); + } } } - } - // Get granted scopes via tokeninfo - let tokeninfo_url = format!( - "https://oauth2.googleapis.com/tokeninfo?access_token={}", - access_token - ); - if let Ok(info_resp) = http_client.get(&tokeninfo_url).send().await - { - if let Ok(info_json) = - info_resp.json::().await + // Get granted scopes via tokeninfo + let tokeninfo_url = format!( + "https://oauth2.googleapis.com/tokeninfo?access_token={}", + access_token + ); + if let Ok(info_resp) = + http_client.get(&tokeninfo_url).send().await { - if let Some(scope_str) = - info_json.get("scope").and_then(|v| v.as_str()) + if let Ok(info_json) = + info_resp.json::().await { - let scopes: Vec<&str> = scope_str.split(' ').collect(); - output["scopes"] = json!(scopes); - output["scope_count"] = json!(scopes.len()); + if let Some(scope_str) = + info_json.get("scope").and_then(|v| v.as_str()) + { + let scopes: Vec<&str> = + scope_str.split(' ').collect(); + output["scopes"] = json!(scopes); + output["scope_count"] = json!(scopes.len()); + } } } - } - } else { - output["token_valid"] = json!(false); - if let Some(err) = - token_json.get("error_description").and_then(|v| v.as_str()) - { - output["token_error"] = json!(err); + } else { + output["token_valid"] = json!(false); + if let Some(err) = + token_json.get("error_description").and_then(|v| v.as_str()) + { + output["token_error"] = json!(err); + } } } } @@ -1445,66 +1724,58 @@ mod tests { use super::*; /// Helper to run resolve_scopes in tests (async). - fn run_resolve_scopes(args: &[String], project_id: Option<&str>) -> Vec { + fn run_resolve_scopes(scope_mode: ScopeMode, project_id: Option<&str>) -> Vec { let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(resolve_scopes(args, project_id, None)) + rt.block_on(resolve_scopes(scope_mode, project_id, None)) } /// Helper to run resolve_scopes with a services filter. fn run_resolve_scopes_with_services( - args: &[String], + scope_mode: ScopeMode, project_id: Option<&str>, services: &[&str], ) -> Vec { let filter: HashSet = services.iter().map(|s| s.to_string()).collect(); let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(resolve_scopes(args, project_id, Some(&filter))) + rt.block_on(resolve_scopes(scope_mode, project_id, Some(&filter))) } #[test] fn resolve_scopes_returns_defaults_when_no_flag() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes(&args, None); + let scopes = run_resolve_scopes(ScopeMode::Default, None); assert_eq!(scopes.len(), DEFAULT_SCOPES.len()); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); } #[test] fn resolve_scopes_returns_custom_scopes() { - let args: Vec = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive.readonly".to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + let scopes = run_resolve_scopes( + ScopeMode::Custom(vec![ + "https://www.googleapis.com/auth/drive.readonly".to_string() + ]), + None, + ); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive.readonly"); } #[test] - fn resolve_scopes_handles_multiple_comma_separated() { - let args: Vec = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive, https://www.googleapis.com/auth/gmail.readonly" - .to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); + fn resolve_scopes_handles_multiple_custom() { + let scopes = run_resolve_scopes( + ScopeMode::Custom(vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "https://www.googleapis.com/auth/gmail.readonly".to_string(), + ]), + None, + ); assert_eq!(scopes.len(), 2); assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); assert_eq!(scopes[1], "https://www.googleapis.com/auth/gmail.readonly"); } - #[test] - fn resolve_scopes_ignores_trailing_flag() { - // --scopes with no value should use defaults - let args: Vec = vec!["--scopes".to_string()]; - let scopes = run_resolve_scopes(&args, None); - assert_eq!(scopes.len(), DEFAULT_SCOPES.len()); - } - #[test] fn resolve_scopes_readonly_returns_readonly_scopes() { - let args = vec!["--readonly".to_string()]; - let scopes = run_resolve_scopes(&args, None); + let scopes = run_resolve_scopes(ScopeMode::Readonly, None); assert_eq!(scopes.len(), READONLY_SCOPES.len()); for scope in &scopes { assert!( @@ -1515,16 +1786,9 @@ mod tests { } #[test] - fn resolve_scopes_custom_overrides_readonly() { - // --scopes takes priority over --readonly - let args = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/drive".to_string(), - "--readonly".to_string(), - ]; - let scopes = run_resolve_scopes(&args, None); - assert_eq!(scopes.len(), 1); - assert_eq!(scopes[0], "https://www.googleapis.com/auth/drive"); + fn resolve_scopes_full_returns_full_scopes() { + let scopes = run_resolve_scopes(ScopeMode::Full, None); + assert_eq!(scopes.len(), FULL_SCOPES.len()); } #[test] @@ -1976,8 +2240,8 @@ mod tests { #[test] fn resolve_scopes_with_services_filter() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "gmail"]); + let scopes = + run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "gmail"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -1994,8 +2258,8 @@ mod tests { #[test] fn resolve_scopes_services_filter_unknown_service_ignored() { - let args: Vec = vec![]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive", "nonexistent"]); + let scopes = + run_resolve_scopes_with_services(ScopeMode::Default, None, &["drive", "nonexistent"]); assert!(!scopes.is_empty()); // Should contain drive scope but not be affected by nonexistent assert!(scopes.iter().any(|s| s.contains("/auth/drive"))); @@ -2003,8 +2267,7 @@ mod tests { #[test] fn resolve_scopes_services_takes_priority_with_readonly() { - let args = vec!["--readonly".to_string()]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]); + let scopes = run_resolve_scopes_with_services(ScopeMode::Readonly, None, &["drive"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -2019,8 +2282,7 @@ mod tests { #[test] fn resolve_scopes_services_takes_priority_with_full() { - let args = vec!["--full".to_string()]; - let scopes = run_resolve_scopes_with_services(&args, None, &["gmail"]); + let scopes = run_resolve_scopes_with_services(ScopeMode::Full, None, &["gmail"]); assert!(!scopes.is_empty()); for scope in &scopes { let short = scope @@ -2035,12 +2297,12 @@ mod tests { #[test] fn resolve_scopes_explicit_scopes_bypass_services_filter() { - // --scopes should take priority over -s - let args = vec![ - "--scopes".to_string(), - "https://www.googleapis.com/auth/calendar".to_string(), - ]; - let scopes = run_resolve_scopes_with_services(&args, None, &["drive"]); + // Custom scopes take priority over services filter + let scopes = run_resolve_scopes_with_services( + ScopeMode::Custom(vec!["https://www.googleapis.com/auth/calendar".to_string()]), + None, + &["drive"], + ); assert_eq!(scopes.len(), 1); assert_eq!(scopes[0], "https://www.googleapis.com/auth/calendar"); } @@ -2118,6 +2380,16 @@ mod tests { assert_eq!(mask_secret("123456789"), "1234...6789"); } + #[test] + fn mask_secret_multibyte_utf8() { + // Multi-byte chars must not panic (previously used byte slicing) + assert_eq!(mask_secret("áéíóúñüÁÉÍÓÚ"), "áéíó...ÉÍÓÚ"); + // Short multi-byte — should fully mask + assert_eq!(mask_secret("café"), "***"); + // Exactly at boundary with multi-byte (9 Greek chars) + assert_eq!(mask_secret("αβγδεζηθι"), "αβγδ...ζηθι"); + } + #[test] fn find_unmatched_services_identifies_missing() { let scopes = vec![ @@ -2208,4 +2480,56 @@ mod tests { let result = extract_scopes_from_doc(&doc, false); assert!(result.is_empty()); } + + #[test] + fn build_proxy_auth_url_encodes_scope_and_redirect_uri() { + let scopes = vec![ + "https://www.googleapis.com/auth/drive".to_string(), + "openid".to_string(), + ]; + let url = build_proxy_auth_url("client id", "http://localhost:8080/callback path", &scopes); + + assert!(url.contains("client_id=client%20id")); + assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback%20path")); + assert!(url.contains(&format!( + "scope={}", + urlencoding("https://www.googleapis.com/auth/drive openid") + ))); + } + + #[test] + fn extract_authorization_code_returns_code() { + let code = + extract_authorization_code("GET /?state=abc&code=4/test-code&scope=openid HTTP/1.1") + .unwrap(); + assert_eq!(code, "4/test-code"); + } + + #[test] + fn extract_authorization_code_rejects_missing_code() { + let err = extract_authorization_code("GET /?state=abc HTTP/1.1").unwrap_err(); + assert!(err.to_string().contains("No authorization code")); + } + + #[test] + fn read_refresh_token_from_cache_reads_encrypted_storage() { + let token_data = r#"[{"token":{"refresh_token":"1//refresh-token"}}]"#; + let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap(); + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, &encrypted).unwrap(); + + let refresh_token = read_refresh_token_from_cache(file.path()).unwrap(); + assert_eq!(refresh_token, "1//refresh-token"); + } + + #[test] + fn read_refresh_token_from_cache_requires_refresh_token() { + let token_data = r#"[{"token":{"access_token":"ya29.no-refresh"}}]"#; + let encrypted = crate::credential_store::encrypt(token_data.as_bytes()).unwrap(); + let mut file = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut file, &encrypted).unwrap(); + + let err = read_refresh_token_from_cache(file.path()).unwrap_err(); + assert!(err.to_string().contains("no refresh token was returned")); + } } diff --git a/crates/google-workspace-cli/src/client.rs b/crates/google-workspace-cli/src/client.rs new file mode 100644 index 00000000..64d66f79 --- /dev/null +++ b/crates/google-workspace-cli/src/client.rs @@ -0,0 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! HTTP client — re-exports from `google_workspace` library crate. + +pub use google_workspace::client::*; diff --git a/src/commands.rs b/crates/google-workspace-cli/src/commands.rs similarity index 100% rename from src/commands.rs rename to crates/google-workspace-cli/src/commands.rs diff --git a/src/credential_store.rs b/crates/google-workspace-cli/src/credential_store.rs similarity index 76% rename from src/credential_store.rs rename to crates/google-workspace-cli/src/credential_store.rs index e985b76b..ffc6db25 100644 --- a/src/credential_store.rs +++ b/crates/google-workspace-cli/src/credential_store.rs @@ -14,6 +14,8 @@ use std::path::PathBuf; +use crate::output::sanitize_for_terminal; + use aes_gcm::aead::{Aead, KeyInit, OsRng}; use aes_gcm::{AeadCore, Aes256Gcm, Nonce}; @@ -31,7 +33,10 @@ fn ensure_key_dir(path: &std::path::Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) { - eprintln!("Warning: failed to set secure permissions on key directory: {e}"); + eprintln!( + "Warning: failed to set secure permissions on key directory: {}", + sanitize_for_terminal(&e.to_string()) + ); } } } @@ -61,29 +66,11 @@ fn save_key_file_exclusive(path: &std::path::Path, b64_key: &str) -> std::io::Re /// Persist the base64-encoded encryption key to a local file with restrictive /// permissions (0600 file, 0700 directory). Overwrites any existing file. +/// Uses atomic_write to prevent TOCTOU/symlink race conditions. +#[allow(dead_code)] fn save_key_file(path: &std::path::Path, b64_key: &str) -> std::io::Result<()> { - use std::io::Write; - ensure_key_dir(path)?; - - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - let mut options = std::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true).mode(0o600); - let mut file = options.open(path)?; - file.write_all(b64_key.as_bytes())?; - file.sync_all()?; // fsync: ensure key is durable before returning - } - #[cfg(not(unix))] - { - std::fs::write(path, b64_key)?; - } - Ok(()) + crate::fs_util::atomic_write(path, b64_key.as_bytes()) } - -/// Read and decode a base64-encoded 256-bit key from a file. -/// -/// On Unix, warns if the file is world-readable (mode & 0o077 != 0). fn read_key_file(path: &std::path::Path) -> Option<[u8; 32]> { use base64::{engine::general_purpose::STANDARD, Engine as _}; @@ -215,61 +202,122 @@ fn resolve_key( // --- 1. Try keyring (only when backend = Keyring) -------------------- if backend == KeyringBackend::Keyring { - match provider.get_password() { - Ok(b64_key) => { - if let Ok(decoded) = STANDARD.decode(&b64_key) { - if decoded.len() == 32 { - let mut arr = [0u8; 32]; - arr.copy_from_slice(&decoded); - // Ensure file backup stays in sync with keyring so - // credentials survive keyring loss (e.g. after OS - // upgrades, container restarts, daemon changes). - if let Err(err) = save_key_file(key_file, &b64_key) { - eprintln!( - "Warning: failed to sync keyring backup file at '{}': {err}", - key_file.display() - ); + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + match provider.get_password() { + Ok(b64_key) => { + if let Ok(decoded) = STANDARD.decode(&b64_key) { + if decoded.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&decoded); + // Cleanup insecure file fallback if it still exists. + // TOCTOU race condition is a known limitation. + if let Err(e) = std::fs::remove_file(key_file) { + if e.kind() != std::io::ErrorKind::NotFound { + eprintln!( + "Warning: failed to remove legacy key file at '{}': {}", + key_file.display(), + e + ); + } + } + return Ok(arr); } - return Ok(arr); } + // Keyring contained invalid data — fall through to generate new. } - // Keyring contained invalid data — fall through to file. + Err(keyring::Error::NoEntry) => { + // Keyring is empty — fall through to generate new. + } + Err(e) => { + anyhow::bail!("OS keyring failed: {}. Set GOOGLE_WORKSPACE_CLI_KEYRING_BACKEND=file to use file storage.", sanitize_for_terminal(&e.to_string())); + } + } + + // Generate a new key if keyring was empty or contained invalid data. + let key = generate_random_key(); + let b64_key = STANDARD.encode(key); + if let Err(e) = provider.set_password(&b64_key) { + anyhow::bail!( + "Failed to set key in OS keyring: {}", + sanitize_for_terminal(&e.to_string()) + ); } - Err(keyring::Error::NoEntry) => { - // Keyring is reachable but empty — check file, then generate. - if let Some(key) = read_key_file(key_file) { - // Best-effort: copy file key into keyring for future runs. - let _ = provider.set_password(&STANDARD.encode(key)); - return Ok(key); + if let Err(e) = std::fs::remove_file(key_file) { + if e.kind() != std::io::ErrorKind::NotFound { + eprintln!( + "Warning: failed to remove legacy key file at '{}': {}", + key_file.display(), + e + ); } + } + return Ok(key); + } - // Generate a new key. - let key = generate_random_key(); - let b64_key = STANDARD.encode(key); - - // Best-effort: store in keyring. - let _ = provider.set_password(&b64_key); - - // Atomically create the file; if another process raced us, - // use their key instead. - match save_key_file_exclusive(key_file, &b64_key) { - Ok(()) => return Ok(key), - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { - if let Some(winner) = read_key_file(key_file) { - // Sync the winner's key into the keyring so both - // backends stay consistent after the race. - let _ = provider.set_password(&STANDARD.encode(winner)); - return Ok(winner); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + // On Linux, keyring uses a mock store by default without C DBus dependencies, + // so we continue to use the file fallback for reliability. + match provider.get_password() { + Ok(b64_key) => { + if let Ok(decoded) = STANDARD.decode(&b64_key) { + if decoded.len() == 32 { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&decoded); + // Ensure file backup stays in sync with keyring so + // credentials survive keyring loss (e.g. after OS + // upgrades, container restarts, daemon changes). + if let Err(err) = save_key_file(key_file, &b64_key) { + eprintln!( + "Warning: failed to sync keyring backup file at '{}': {err}", + key_file.display() + ); + } + return Ok(arr); } - // File exists but is unreadable/corrupt — overwrite. - save_key_file(key_file, &b64_key)?; + } + // Keyring contained invalid data — fall through to file. + } + Err(keyring::Error::NoEntry) => { + // Keyring is reachable but empty — check file, then generate. + if let Some(key) = read_key_file(key_file) { + // Best-effort: copy file key into keyring for future runs. + let _ = provider.set_password(&STANDARD.encode(key)); return Ok(key); } - Err(e) => return Err(e.into()), + + // Generate a new key. + let key = generate_random_key(); + let b64_key = STANDARD.encode(key); + + // Best-effort: store in keyring. + let _ = provider.set_password(&b64_key); + + // Atomically create the file; if another process raced us, + // use their key instead. + match save_key_file_exclusive(key_file, &b64_key) { + Ok(()) => return Ok(key), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + if let Some(winner) = read_key_file(key_file) { + // Sync the winner's key into the keyring so both + // backends stay consistent after the race. + let _ = provider.set_password(&STANDARD.encode(winner)); + return Ok(winner); + } + // File exists but is unreadable/corrupt — overwrite. + save_key_file(key_file, &b64_key)?; + return Ok(key); + } + Err(e) => return Err(e.into()), + } + } + Err(e) => { + eprintln!( + "Warning: keyring access failed, falling back to file storage: {}", + sanitize_for_terminal(&e.to_string()) + ); } - } - Err(e) => { - eprintln!("Warning: keyring access failed, falling back to file storage: {e}"); } } } @@ -303,7 +351,11 @@ fn get_or_create_key() -> anyhow::Result<[u8; 32]> { return Ok(*key); } + #[cfg(not(test))] let backend = KeyringBackend::from_env(); + #[cfg(test)] + let backend = KeyringBackend::File; // Force file to avoid native keychain prompts during test execution + // Item 5: log which backend was selected eprintln!("Using keyring backend: {}", backend.as_str()); @@ -400,18 +452,6 @@ pub fn save_encrypted(json: &str) -> anyhow::Result { crate::fs_util::atomic_write(&path, &encrypted) .map_err(|e| anyhow::anyhow!("Failed to write credentials: {e}"))?; - // Set permissions to 600 on Unix (contains secrets) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) { - eprintln!( - "Warning: failed to set file permissions on {}: {e}", - path.display() - ); - } - } - Ok(path) } @@ -429,6 +469,8 @@ pub fn load_encrypted() -> anyhow::Result { #[cfg(test)] mod tests { + #![allow(dead_code)] + use super::*; use std::cell::RefCell; @@ -535,7 +577,73 @@ mod tests { assert_eq!(result, expected); } + // ---- Backend::Keyring tests (macOS/Windows specific behavior) ---- + + #[test] + #[cfg(any(target_os = "macos", target_os = "windows"))] + fn keyring_backend_cleans_up_legacy_file_on_success() { + use base64::{engine::general_purpose::STANDARD, Engine as _}; + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join(".encryption_key"); + + // Create a legacy fallback file + std::fs::write(&key_file, STANDARD.encode([99u8; 32])).unwrap(); + assert!(key_file.exists()); + + // Keyring has a valid key + let expected = [7u8; 32]; + let mock = MockKeyring::with_password(&STANDARD.encode(expected)); + + let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap(); + + assert_eq!(result, expected); + assert!( + !key_file.exists(), + "Legacy file must be deleted upon successful keyring read" + ); + } + + #[test] + #[cfg(any(target_os = "macos", target_os = "windows"))] + fn keyring_backend_cleans_up_legacy_file_on_generation() { + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join(".encryption_key"); + + std::fs::write(&key_file, "legacy-data").unwrap(); + assert!(key_file.exists()); + + let mock = MockKeyring::no_entry(); + + let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file).unwrap(); + + assert_eq!(result.len(), 32); + assert!( + !key_file.exists(), + "Legacy file must be deleted upon successful keyring generation" + ); + } + + #[test] + #[cfg(any(target_os = "macos", target_os = "windows"))] + fn keyring_backend_returns_error_on_platform_failure() { + let dir = tempfile::tempdir().unwrap(); + let key_file = dir.path().join(".encryption_key"); + + let mock = MockKeyring::platform_error(); + + let result = resolve_key(KeyringBackend::Keyring, &mock, &key_file); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("OS keyring failed")); + } + + // ---- Backend::Keyring tests (Linux fallback behavior) ---- + #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_creates_file_backup_when_missing() { use base64::{engine::general_purpose::STANDARD, Engine as _}; let dir = tempfile::tempdir().unwrap(); @@ -557,6 +665,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_syncs_file_when_keyring_differs() { use base64::{engine::general_purpose::STANDARD, Engine as _}; let dir = tempfile::tempdir().unwrap(); @@ -576,6 +685,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_no_entry_reads_file() { let dir = tempfile::tempdir().unwrap(); let (expected, key_file) = write_test_key(dir.path()); @@ -590,6 +700,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_no_entry_no_file_generates_and_saves_both() { let dir = tempfile::tempdir().unwrap(); let key_file = dir.path().join(".encryption_key"); @@ -603,6 +714,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_no_entry_no_file_keyring_set_fails() { let dir = tempfile::tempdir().unwrap(); let key_file = dir.path().join(".encryption_key"); @@ -615,6 +727,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_platform_error_falls_back_to_file() { let dir = tempfile::tempdir().unwrap(); let (expected, key_file) = write_test_key(dir.path()); @@ -624,6 +737,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_platform_error_no_file_generates() { let dir = tempfile::tempdir().unwrap(); let key_file = dir.path().join(".encryption_key"); @@ -634,6 +748,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn keyring_backend_invalid_keyring_data_uses_file() { use base64::{engine::general_purpose::STANDARD, Engine as _}; let dir = tempfile::tempdir().unwrap(); @@ -682,6 +797,7 @@ mod tests { // ---- Stability tests ---- #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn key_is_stable_across_calls() { let dir = tempfile::tempdir().unwrap(); let key_file = dir.path().join(".encryption_key"); @@ -880,6 +996,7 @@ mod tests { // ---- Race condition tests ---- #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn race_loser_syncs_winner_key_to_keyring() { use base64::{engine::general_purpose::STANDARD, Engine as _}; let dir = tempfile::tempdir().unwrap(); @@ -903,6 +1020,7 @@ mod tests { } #[test] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] fn race_loser_corrupt_file_overwrites() { let dir = tempfile::tempdir().unwrap(); let key_file = dir.path().join(".encryption_key"); diff --git a/crates/google-workspace-cli/src/discovery.rs b/crates/google-workspace-cli/src/discovery.rs new file mode 100644 index 00000000..a6dfc781 --- /dev/null +++ b/crates/google-workspace-cli/src/discovery.rs @@ -0,0 +1,33 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Discovery Document types and fetching. +//! +//! Types are re-exported from the `google_workspace` library crate. +//! The CLI wrapper provides default caching via `config_dir()`. + +pub use google_workspace::discovery::*; + +/// Fetches and caches a Google Discovery Document using the CLI's config directory. +/// +/// This is a convenience wrapper around +/// [`google_workspace::discovery::fetch_discovery_document`] that automatically +/// uses the CLI's cache directory (`~/.config/gws/cache/`). +pub async fn fetch_discovery_document( + service: &str, + version: &str, +) -> anyhow::Result { + let cache_dir = crate::auth_commands::config_dir().join("cache"); + google_workspace::discovery::fetch_discovery_document(service, version, Some(&cache_dir)).await +} diff --git a/crates/google-workspace-cli/src/error.rs b/crates/google-workspace-cli/src/error.rs new file mode 100644 index 00000000..b5b389bd --- /dev/null +++ b/crates/google-workspace-cli/src/error.rs @@ -0,0 +1,153 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Structured error types and CLI error output. +//! +//! Core error types are re-exported from the `google_workspace` library crate. +//! CLI-specific error formatting (colored terminal output) is defined here. + +pub use google_workspace::error::*; + +use crate::output::{colorize, sanitize_for_terminal}; + +/// Human-readable exit code table, keyed by (code, description). +/// +/// Used by `print_usage()` so the help text stays in sync with the +/// constants defined below without requiring manual updates in two places. +pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[ + (0, "Success"), + ( + GwsError::EXIT_CODE_API, + "API error — Google returned an error response", + ), + ( + GwsError::EXIT_CODE_AUTH, + "Auth error — credentials missing or invalid", + ), + ( + GwsError::EXIT_CODE_VALIDATION, + "Validation — bad arguments or input", + ), + ( + GwsError::EXIT_CODE_DISCOVERY, + "Discovery — could not fetch API schema", + ), + (GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"), +]; + +/// Format a colored error label for the given error variant. +fn error_label(err: &GwsError) -> String { + match err { + GwsError::Api { .. } => colorize("error[api]:", "31"), // red + GwsError::Auth(_) => colorize("error[auth]:", "31"), // red + GwsError::Validation(_) => colorize("error[validation]:", "33"), // yellow + GwsError::Discovery(_) => colorize("error[discovery]:", "31"), // red + GwsError::Other(_) => colorize("error:", "31"), // red + } +} + +/// Formats any error as a JSON object and prints to stdout. +/// +/// A human-readable colored label is printed to stderr when connected to a +/// TTY. For `accessNotConfigured` errors (HTTP 403, reason +/// `accessNotConfigured`), additional guidance is printed to stderr. +/// The JSON output on stdout is unchanged (machine-readable). +pub fn print_error_json(err: &GwsError) { + let json = err.to_json(); + println!( + "{}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + + // Print a colored summary to stderr. For accessNotConfigured errors, + // print specialized guidance instead of the generic message to avoid + // redundant output (the full API error already appears in the JSON). + if let GwsError::Api { + reason, enable_url, .. + } = err + { + if reason == "accessNotConfigured" { + eprintln!(); + let hint = colorize("hint:", "36"); // cyan + eprintln!( + "{} {hint} API not enabled for your GCP project.", + error_label(err) + ); + if let Some(url) = enable_url { + eprintln!(" Enable it at: {url}"); + } else { + eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API."); + } + eprintln!(" After enabling, wait a few seconds and retry your command."); + return; + } + } + eprintln!( + "{} {}", + error_label(err), + sanitize_for_terminal(&err.to_string()) + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[serial_test::serial] + fn test_colorize_respects_no_color_env() { + std::env::set_var("NO_COLOR", "1"); + let result = colorize("hello", "31"); + std::env::remove_var("NO_COLOR"); + assert_eq!(result, "hello"); + } + + #[test] + fn test_error_label_contains_variant_name() { + let api_err = GwsError::Api { + code: 400, + message: "bad".to_string(), + reason: "r".to_string(), + enable_url: None, + }; + let label = error_label(&api_err); + assert!(label.contains("error[api]:")); + + let auth_err = GwsError::Auth("fail".to_string()); + assert!(error_label(&auth_err).contains("error[auth]:")); + + let val_err = GwsError::Validation("bad input".to_string()); + assert!(error_label(&val_err).contains("error[validation]:")); + + let disc_err = GwsError::Discovery("missing".to_string()); + assert!(error_label(&disc_err).contains("error[discovery]:")); + + let other_err = GwsError::Other(anyhow::anyhow!("oops")); + assert!(error_label(&other_err).contains("error:")); + } + + #[test] + fn test_sanitize_for_terminal_strips_control_chars() { + let input = "normal \x1b[31mred text\x1b[0m end"; + let sanitized = sanitize_for_terminal(input); + assert_eq!(sanitized, "normal [31mred text[0m end"); + assert!(!sanitized.contains('\x1b')); + + let input2 = "line1\nline2\ttab"; + assert_eq!(sanitize_for_terminal(input2), "line1\nline2\ttab"); + + let input3 = "hello\x07bell\x08backspace"; + assert_eq!(sanitize_for_terminal(input3), "hellobellbackspace"); + } +} diff --git a/src/executor.rs b/crates/google-workspace-cli/src/executor.rs similarity index 92% rename from src/executor.rs rename to crates/google-workspace-cli/src/executor.rs index 73fd772f..46f31ac4 100644 --- a/src/executor.rs +++ b/crates/google-workspace-cli/src/executor.rs @@ -29,6 +29,7 @@ use tokio::io::AsyncWriteExt; use crate::discovery::{RestDescription, RestMethod}; use crate::error::GwsError; +use crate::output::sanitize_for_terminal; /// Tracks what authentication method was used for the request. #[derive(Debug, Clone, PartialEq)] @@ -39,6 +40,26 @@ pub enum AuthMethod { None, } +/// Source for media upload content. +/// +/// Two mutually exclusive strategies: upload from a file on disk (for Drive, +/// Chat, etc.) or from in-memory bytes (for Gmail's constructed RFC 5322 +/// messages). Using an enum makes illegal states (both set, or mismatched +/// content types) unrepresentable. +pub enum UploadSource<'a> { + /// Stream from a file on disk. Content type is inferred from the file + /// extension, overridden by metadata mimeType, or explicitly set. + File { + path: &'a str, + content_type: Option<&'a str>, + }, + /// Upload from in-memory bytes with an explicit content type. + Bytes { + data: &'a [u8], + content_type: &'a str, + }, +} + /// Configuration for auto-pagination. #[derive(Debug, Clone)] pub struct PaginationConfig { @@ -76,7 +97,7 @@ fn parse_and_validate_inputs( method: &RestMethod, params_json: Option<&str>, body_json: Option<&str>, - upload_path: Option<&str>, + is_media_upload: bool, ) -> Result { let params: Map = if let Some(p) = params_json { serde_json::from_str(p) @@ -123,8 +144,8 @@ fn parse_and_validate_inputs( } } - let (full_url, query_params) = build_url(doc, method, ¶ms, upload_path.is_some())?; - let is_upload = upload_path.is_some() && method.supports_media_upload; + let (full_url, query_params) = build_url(doc, method, ¶ms, is_media_upload)?; + let is_upload = is_media_upload && method.supports_media_upload; Ok(ExecutionInput { params, @@ -145,8 +166,7 @@ async fn build_http_request( auth_method: &AuthMethod, page_token: Option<&str>, pages_fetched: u32, - upload_path: Option<&str>, - upload_content_type: Option<&str>, + upload: &Option>, ) -> Result { let mut request = match method.http_method.as_str() { "GET" => client.get(&input.full_url), @@ -181,22 +201,29 @@ async fn build_http_request( } if pages_fetched == 0 { - if input.is_upload { - let upload_path = upload_path.expect("upload_path must be Some when is_upload is true"); - - let file_meta = tokio::fs::metadata(upload_path).await.map_err(|e| { - GwsError::Validation(format!( - "Failed to get metadata for upload file '{}': {}", - upload_path, e - )) - })?; - let file_size = file_meta.len(); - + if let Some(upload_source) = upload { request = request.query(&[("uploadType", "multipart")]); - let media_mime = - resolve_upload_mime(upload_content_type, Some(upload_path), &input.body); - let (body, content_type, content_length) = - build_multipart_stream(&input.body, upload_path, file_size, &media_mime)?; + let (body, content_type, content_length) = match upload_source { + UploadSource::Bytes { data, content_type } => { + if content_type.contains('\r') || content_type.contains('\n') { + return Err(GwsError::Validation( + "Upload content type must not contain CR or LF".to_string(), + )); + } + build_multipart_bytes(&input.body, data, content_type)? + } + UploadSource::File { path, content_type } => { + let file_meta = tokio::fs::metadata(path).await.map_err(|e| { + GwsError::Validation(format!( + "Failed to get metadata for upload file '{}': {}", + path, e + )) + })?; + let file_size = file_meta.len(); + let media_mime = resolve_upload_mime(*content_type, Some(path), &input.body); + build_multipart_stream(&input.body, path, file_size, &media_mime)? + } + }; request = request.header("Content-Type", content_type); request = request.header("Content-Length", content_length); request = request.body(body); @@ -261,7 +288,10 @@ async fn handle_json_response( } } Err(e) => { - eprintln!("⚠️ Model Armor sanitization failed: {e}"); + eprintln!( + "⚠️ Model Armor sanitization failed: {}", + sanitize_for_terminal(&e.to_string()) + ); } } } @@ -373,8 +403,7 @@ pub async fn execute_method( token: Option<&str>, auth_method: AuthMethod, output_path: Option<&str>, - upload_path: Option<&str>, - upload_content_type: Option<&str>, + upload: Option>, dry_run: bool, pagination: &PaginationConfig, sanitize_template: Option<&str>, @@ -382,7 +411,7 @@ pub async fn execute_method( output_format: &crate::formatter::OutputFormat, capture_output: bool, ) -> Result, GwsError> { - let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload_path)?; + let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some())?; if dry_run { let dry_run_info = json!({ @@ -417,8 +446,7 @@ pub async fn execute_method( &auth_method, page_token.as_deref(), pages_fetched, - upload_path, - upload_content_type, + &upload, ) .await?; @@ -798,56 +826,33 @@ fn handle_error_response( /// represents the *source* type (what the bytes are). When a user uploads /// `notes.md` with `"mimeType":"application/vnd.google-apps.document"`, the /// media part should be `text/markdown`, not a Google Workspace MIME type. +/// All returned MIME types have control characters stripped to prevent +/// MIME header injection via user-controlled metadata. fn resolve_upload_mime( explicit: Option<&str>, upload_path: Option<&str>, metadata: &Option, ) -> String { - if let Some(mime) = explicit { - return mime.to_string(); - } - - if let Some(path) = upload_path { - if let Some(detected) = mime_from_extension(path) { - return detected.to_string(); - } - } - - if let Some(mime) = metadata - .as_ref() - .and_then(|m| m.get("mimeType")) - .and_then(|v| v.as_str()) - { - return mime.to_string(); - } - - "application/octet-stream".to_string() -} + let raw = explicit + .map(|s| s.to_string()) + .or_else(|| { + upload_path.and_then(|path| mime_guess2::from_path(path).first().map(|m| m.to_string())) + }) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| m.get("mimeType")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); -/// Infers a MIME type from a file path's extension. -fn mime_from_extension(path: &str) -> Option<&'static str> { - let ext = std::path::Path::new(path) - .extension() - .and_then(|e| e.to_str())?; - match ext.to_lowercase().as_str() { - "md" | "markdown" => Some("text/markdown"), - "html" | "htm" => Some("text/html"), - "txt" => Some("text/plain"), - "json" => Some("application/json"), - "csv" => Some("text/csv"), - "xml" => Some("application/xml"), - "pdf" => Some("application/pdf"), - "png" => Some("image/png"), - "jpg" | "jpeg" => Some("image/jpeg"), - "gif" => Some("image/gif"), - "svg" => Some("image/svg+xml"), - "doc" => Some("application/msword"), - "docx" => Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), - "xls" => Some("application/vnd.ms-excel"), - "xlsx" => Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), - "ppt" => Some("application/vnd.ms-powerpoint"), - "pptx" => Some("application/vnd.openxmlformats-officedocument.presentationml.presentation"), - _ => None, + // Strip CR/LF and other control characters to prevent MIME header injection. + let sanitized: String = raw.chars().filter(|c| !c.is_control()).collect(); + if sanitized.is_empty() { + "application/octet-stream".to_string() + } else { + sanitized } } @@ -913,6 +918,41 @@ fn build_multipart_stream( )) } +/// Builds a multipart/related body from in-memory bytes. +/// +/// Used when the upload content is constructed in memory (e.g., a Gmail RFC 5322 +/// message with attachments) rather than read from a file on disk. +fn build_multipart_bytes( + metadata: &Option, + data: &[u8], + media_mime: &str, +) -> Result<(reqwest::body, String, u64), GwsError> { + let boundary = format!("gws_boundary_{:016x}", rand::random::()); + + let metadata_json = match metadata { + Some(m) => serde_json::to_string(m).map_err(|e| { + GwsError::Validation(format!("Failed to serialize upload metadata: {e}")) + })?, + None => "{}".to_string(), + }; + + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: {media_mime}\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + + let mut body = Vec::with_capacity(preamble.len() + data.len() + postamble.len()); + body.extend_from_slice(preamble.as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(postamble.as_bytes()); + + let content_length = body.len() as u64; + let content_type = format!("multipart/related; boundary={boundary}"); + + Ok((reqwest::Body::from(body), content_type, content_length)) +} + /// Builds a buffered multipart/related body for media upload requests. /// /// This is the legacy implementation retained for unit tests that need @@ -1459,6 +1499,33 @@ mod tests { ); } + #[test] + fn test_build_multipart_bytes_with_metadata() { + let metadata = Some(json!({ "threadId": "thread-123" })); + let data = b"From: test@example.com\r\nSubject: Test\r\n\r\nBody"; + let (_, content_type, content_length) = + build_multipart_bytes(&metadata, data, "message/rfc822").unwrap(); + + assert!( + content_type.starts_with("multipart/related; boundary=gws_boundary_"), + "content_type should be multipart/related: {content_type}", + ); + // Content-length should cover: preamble + data + postamble + assert!( + content_length> data.len() as u64, + "content_length should exceed raw data size: {content_length}", + ); + } + + #[test] + fn test_build_multipart_bytes_without_metadata() { + let (_, content_type, content_length) = + build_multipart_bytes(&None, b"test body", "message/rfc822").unwrap(); + + assert!(content_type.starts_with("multipart/related; boundary=")); + assert!(content_length> 0); + } + #[tokio::test] async fn test_build_multipart_stream_content_length() { let dir = tempfile::tempdir().unwrap(); @@ -2024,7 +2091,6 @@ async fn test_execute_method_dry_run() { AuthMethod::None, None, None, - None, true, // dry_run &pagination, None, @@ -2068,7 +2134,6 @@ async fn test_execute_method_missing_path_param() { AuthMethod::None, None, None, - None, true, &PaginationConfig::default(), None, @@ -2245,8 +2310,7 @@ async fn test_post_without_body_sets_content_length_zero() { &AuthMethod::None, None, 0, - None, - None, + &None, ) .await .unwrap(); @@ -2286,8 +2350,7 @@ async fn test_post_with_body_does_not_add_content_length_zero() { &AuthMethod::None, None, 0, - None, - None, + &None, ) .await .unwrap(); @@ -2325,8 +2388,7 @@ async fn test_get_does_not_set_content_length_zero() { &AuthMethod::None, None, 0, - None, - None, + &None, ) .await .unwrap(); diff --git a/src/formatter.rs b/crates/google-workspace-cli/src/formatter.rs similarity index 100% rename from src/formatter.rs rename to crates/google-workspace-cli/src/formatter.rs diff --git a/crates/google-workspace-cli/src/fs_util.rs b/crates/google-workspace-cli/src/fs_util.rs new file mode 100644 index 00000000..fab255b0 --- /dev/null +++ b/crates/google-workspace-cli/src/fs_util.rs @@ -0,0 +1,166 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! File-system utilities. + +use std::io::{self, Write}; +use std::path::Path; + +/// Write `data` to `path` atomically. +/// +/// This implementation uses `tempfile::NamedTempFile` to create a temporary +/// file with a random name, `O_EXCL` flags (preventing symlink attacks), +/// and secure 0600 permissions from the moment of creation. +/// +/// # Errors +/// +/// Returns an `io::Error` if the temporary file cannot be created/written or if the +/// final rename fails. +pub fn atomic_write(path: &Path, data: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory") + })?; + + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(data)?; + tmp.as_file().sync_all()?; + tmp.persist(path) + .map_err(|e| io::Error::new(e.error.kind(), e.error))?; + + Ok(()) +} + +/// Async variant of [`atomic_write`] for use with tokio. +/// +/// This implementation uses `create_new(true)` (O_EXCL) and `mode(0o600)` to +/// prevent TOCTOU/symlink race conditions. +pub async fn atomic_write_async(path: &Path, data: &[u8]) -> io::Result<()> { + use rand::Rng; + use tokio::io::AsyncWriteExt; + + let parent = path.parent().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "path has no parent directory") + })?; + let file_name = path + .file_name() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "path has no file name"))? + .to_string_lossy(); + + let mut retries = 0; + let mut file: tokio::fs::File; + let mut tmp_path; + + loop { + let suffix: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(8) + .map(char::from) + .collect(); + let tmp_name = format!("{}.tmp.{}", file_name, suffix); + tmp_path = parent.join(tmp_name); + + let mut opts = tokio::fs::OpenOptions::new(); + opts.write(true).create_new(true); + + #[cfg(unix)] + { + opts.mode(0o600); + } + + match opts.open(&tmp_path).await { + Ok(f) => { + file = f; + break; + } + Err(e) if e.kind() == io::ErrorKind::AlreadyExists && retries < 10 => { + retries += 1; + continue; + } + Err(e) => return Err(e), + } + } + + let write_result = async { + file.write_all(data).await?; + file.sync_all().await?; + drop(file); + tokio::fs::rename(&tmp_path, path).await + } + .await; + + if write_result.is_err() { + let _ = tokio::fs::remove_file(&tmp_path).await; + } + + write_result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_atomic_write_creates_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.enc"); + atomic_write(&path, b"hello").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"hello"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = fs::metadata(&path).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + } + } + + #[test] + fn test_atomic_write_overwrites_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.enc"); + fs::write(&path, b"old").unwrap(); + atomic_write(&path, b"new").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"new"); + } + + #[test] + fn test_atomic_write_leaves_no_tmp_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.enc"); + atomic_write(&path, b"data").unwrap(); + // Since we use random names, we just check that no .tmp files remain in the dir + let files: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .map(|res| res.unwrap().file_name()) + .collect(); + assert_eq!(files.len(), 1); + assert_eq!(files[0], "credentials.enc"); + } + + #[tokio::test] + async fn test_atomic_write_async_creates_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token_cache.json"); + atomic_write_async(&path, b"async hello").await.unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"async hello"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = fs::metadata(&path).unwrap(); + assert_eq!(meta.permissions().mode() & 0o777, 0o600); + } + } +} diff --git a/src/generate_skills.rs b/crates/google-workspace-cli/src/generate_skills.rs similarity index 79% rename from src/generate_skills.rs rename to crates/google-workspace-cli/src/generate_skills.rs index ba2496b5..ce265407 100644 --- a/src/generate_skills.rs +++ b/crates/google-workspace-cli/src/generate_skills.rs @@ -19,12 +19,13 @@ use crate::commands; use crate::discovery; use crate::error::GwsError; +use crate::output::sanitize_for_terminal; use crate::services; use clap::Command; use std::path::Path; -const PERSONAS_YAML: &str = include_str!("../registry/personas.yaml"); -const RECIPES_YAML: &str = include_str!("../registry/recipes.yaml"); +const PERSONAS_TOML: &str = include_str!("../registry/personas.toml"); +const RECIPES_TOML: &str = include_str!("../registry/recipes.toml"); /// Methods blocked from skill generation. /// Format: (service_alias, resource, method). @@ -123,7 +124,10 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { match discovery::fetch_discovery_document(entry.api_name, entry.version).await { Ok(d) => d, Err(e) => { - eprintln!(" WARNING: Failed to fetch discovery doc for {alias}: {e}"); + eprintln!( + " WARNING: Failed to fetch discovery doc for {alias}: {}", + sanitize_for_terminal(&e.to_string()) + ); continue; } } @@ -198,12 +202,11 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { } } - // Generate Personas if filter .as_ref() .is_none_or(|f| "persona".contains(f.as_str()) || "personas".contains(f.as_str())) { - if let Ok(registry) = serde_yaml::from_str::(PERSONAS_YAML) { + if let Ok(registry) = toml::from_str::(PERSONAS_TOML) { eprintln!( "Generating skills for {} personas...", registry.personas.len() @@ -225,7 +228,7 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { } } } else { - eprintln!("WARNING: Failed to parse personas.yaml"); + eprintln!("WARNING: Failed to parse personas.toml"); } } @@ -234,7 +237,7 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { .as_ref() .is_none_or(|f| "recipe".contains(f.as_str()) || "recipes".contains(f.as_str())) { - if let Ok(registry) = serde_yaml::from_str::(RECIPES_YAML) { + if let Ok(registry) = toml::from_str::(RECIPES_TOML) { eprintln!( "Generating skills for {} recipes...", registry.recipes.len() @@ -256,7 +259,7 @@ pub async fn handle_generate_skills(args: &[String]) -> Result<(), GwsError> { } } } else { - eprintln!("WARNING: Failed to parse recipes.yaml"); + eprintln!("WARNING: Failed to parse recipes.toml"); } } @@ -381,17 +384,19 @@ fn render_service_skill( out.push_str(&format!( r#"--- name: gws-{alias} -version: 1.0.0 description: "{trigger_desc}" metadata: + version: {version} openclaw: category: "productivity" requires: - bins: ["gws"] + bins: + - gws cliHelp: "gws {alias} --help" --- "#, + version = env!("CARGO_PKG_VERSION"), )); // Title @@ -517,17 +522,19 @@ fn render_helper_skill( out.push_str(&format!( r#"--- name: gws-{alias}-{short} -version: 1.0.0 description: "{trigger_desc}" metadata: + version: {version} openclaw: category: "{category}" requires: - bins: ["gws"] + bins: + - gws cliHelp: "gws {alias} {cmd_name} --help" --- "#, + version = env!("CARGO_PKG_VERSION"), )); // Title @@ -664,13 +671,14 @@ metadata: fn generate_shared_skill(base: &Path) -> Result<(), GwsError> { let content = r#"--- name: gws-shared -version: 1.0.0 description: "gws CLI: Shared patterns for authentication, global flags, and output formatting." metadata: + version: __VERSION__ openclaw: category: "productivity" requires: - bins: ["gws"] + bins: + - gws --- # gws — Shared Reference @@ -743,35 +751,38 @@ gws [sub-resource] [flags] - For bugs or feature requests, direct users to open issues in the repository: `https://github.com/googleworkspace/cli/issues` - Before creating a new issue, **always** search existing issues and feature requests first - If a matching issue already exists, add context by commenting on the existing thread instead of creating a duplicate -"#; +"# + .replace("__VERSION__", env!("CARGO_PKG_VERSION")); - write_skill(base, "gws-shared", content) + write_skill(base, "gws-shared", &content) } fn render_persona_skill(persona: &PersonaEntry) -> String { let mut out = String::new(); - // metadata JSON string for skills array + // Block-style YAML for skills array let required_skills = persona .services .iter() - .map(|s| format!("\"gws-{s}\"")) + .map(|s| format!(" - gws-{s}")) .collect::>() - .join(", "); + .join("\n"); let trigger_desc = truncate_desc(&persona.description); out.push_str(&format!( r#"--- name: persona-{name} -version: 1.0.0 description: "{trigger_desc}" metadata: + version: {version} openclaw: category: "persona" requires: - bins: ["gws"] - skills: [{skills}] + bins: + - gws + skills: +{skills} --- # {title} @@ -795,6 +806,7 @@ metadata: .map(|s| format!("`gws-{s}`")) .collect::>() .join(", "), + version = env!("CARGO_PKG_VERSION"), workflows = persona .workflows .iter() @@ -825,24 +837,26 @@ fn render_recipe_skill(recipe: &RecipeEntry) -> String { let required_skills = recipe .services .iter() - .map(|s| format!("\"gws-{s}\"")) + .map(|s| format!(" - gws-{s}")) .collect::>() - .join(", "); + .join("\n"); let trigger_desc = truncate_desc(&recipe.description); out.push_str(&format!( r#"--- name: recipe-{name} -version: 1.0.0 description: "{trigger_desc}" metadata: + version: {version} openclaw: category: "recipe" domain: "{category}" requires: - bins: ["gws"] - skills: [{skills}] + bins: + - gws + skills: +{skills} --- # {title} @@ -856,6 +870,7 @@ metadata: description = recipe.description, title = recipe.title, category = recipe.category, + version = env!("CARGO_PKG_VERSION"), skills = required_skills, skills_list = recipe .services @@ -967,10 +982,8 @@ mod tests { #[test] fn test_registry_references() { - let personas: PersonaRegistry = - serde_yaml::from_str(PERSONAS_YAML).expect("valid personas yaml"); - let recipes: RecipeRegistry = - serde_yaml::from_str(RECIPES_YAML).expect("valid recipes yaml"); + let personas: PersonaRegistry = toml::from_str(PERSONAS_TOML).expect("valid personas toml"); + let recipes: RecipeRegistry = toml::from_str(RECIPES_TOML).expect("valid recipes toml"); // Valid services mapped by api_name or alias let all_services = services::SERVICES; @@ -1183,4 +1196,208 @@ mod tests { fn test_product_name_from_title_adds_google() { assert_eq!(product_name_from_title("Drive API"), "Google Drive"); } + + /// Extract the YAML frontmatter (between `---` delimiters) from a skill string. + fn extract_frontmatter(content: &str) -> &str { + let content = content.strip_prefix("---").expect("no opening ---"); + let (frontmatter, _) = content.split_once("\n---").expect("no closing ---"); + frontmatter + } + + /// Asserts that the frontmatter uses block-style YAML sequences. + /// + /// Detects flow sequences by checking whether YAML values start with `[`, + /// rather than looking for brackets anywhere in a line. This avoids false + /// positives from string values that legitimately contain brackets + /// (e.g., `description: 'Note: [INTERNAL] ticket was filed'`). + fn assert_block_style_sequences(frontmatter: &str) { + for (i, line) in frontmatter.lines().enumerate() { + let trimmed = line.trim(); + // Skip lines that don't look like YAML values (e.g., comments, empty) + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + // A YAML flow sequence is "key: [...]". Check the value after `:`. + if let Some(colon_pos) = trimmed.find(':') { + let value = trimmed[colon_pos + 1..].trim(); + // A flow sequence is not quoted. A quoted string is a scalar. + let is_quoted = value.starts_with('"') || value.starts_with('\''); + assert!( + is_quoted || !value.starts_with('['), + "Flow sequence found on line {} of frontmatter: {:?}\n\ + Use block-style sequences instead (e.g., `- value`)", + i + 1, + trimmed + ); + } + } + } + + #[test] + fn test_service_skill_frontmatter_uses_block_sequences() { + let entry = &services::SERVICES[0]; // first service + let doc = crate::discovery::RestDescription { + name: entry.api_name.to_string(), + title: Some("Test API".to_string()), + description: Some(entry.description.to_string()), + ..Default::default() + }; + let cli = crate::commands::build_cli(&doc); + let helpers: Vec<&command> = cli + .get_subcommands() + .filter(|s| s.get_name().starts_with('+')) + .collect(); + let resources: Vec<&command> = cli + .get_subcommands() + .filter(|s| !s.get_name().starts_with('+')) + .collect(); + let product_name = product_name_from_title("Test API"); + let md = render_service_skill( + entry.aliases[0], + entry, + &helpers, + &resources, + &product_name, + &doc, + ); + let fm = extract_frontmatter(&md); + assert_block_style_sequences(fm); + assert!( + fm.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), + "frontmatter should contain version matching CLI version" + ); + assert!( + fm.contains("bins:\n"), + "frontmatter should contain 'bins:' on its own line" + ); + assert!( + fm.contains("- gws"), + "frontmatter should contain '- gws' block entry" + ); + } + + #[test] + fn test_shared_skill_frontmatter_uses_block_sequences() { + let tmp = tempfile::tempdir().unwrap(); + generate_shared_skill(tmp.path()).unwrap(); + let content = std::fs::read_to_string(tmp.path().join("gws-shared/SKILL.md")).unwrap(); + let fm = extract_frontmatter(&content); + assert_block_style_sequences(fm); + assert!( + fm.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), + "shared skill frontmatter should contain version matching CLI version" + ); + assert!( + fm.contains("- gws"), + "shared skill frontmatter should contain '- gws'" + ); + } + + #[test] + fn test_persona_skill_frontmatter_uses_block_sequences() { + let persona = PersonaEntry { + name: "test-persona".to_string(), + title: "Test Persona".to_string(), + description: "A test persona for unit tests.".to_string(), + services: vec!["gmail".to_string(), "calendar".to_string()], + workflows: vec![], + instructions: vec!["Do this.".to_string()], + tips: vec![], + }; + let md = render_persona_skill(&persona); + let fm = extract_frontmatter(&md); + assert_block_style_sequences(fm); + assert!( + fm.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), + "persona frontmatter should contain version matching CLI version" + ); + assert!( + fm.contains("- gws"), + "persona frontmatter should contain '- gws'" + ); + assert!( + fm.contains("- gws-gmail"), + "persona frontmatter should contain '- gws-gmail'" + ); + assert!( + fm.contains("- gws-calendar"), + "persona frontmatter should contain '- gws-calendar'" + ); + } + + #[test] + fn test_recipe_skill_frontmatter_uses_block_sequences() { + let recipe = RecipeEntry { + name: "test-recipe".to_string(), + title: "Test Recipe".to_string(), + description: "A test recipe for unit tests.".to_string(), + category: "testing".to_string(), + services: vec!["drive".to_string(), "sheets".to_string()], + steps: vec!["Step one.".to_string()], + caution: None, + }; + let md = render_recipe_skill(&recipe); + let fm = extract_frontmatter(&md); + assert_block_style_sequences(fm); + assert!( + fm.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), + "recipe frontmatter should contain version matching CLI version" + ); + assert!( + fm.contains("- gws"), + "recipe frontmatter should contain '- gws'" + ); + assert!( + fm.contains("- gws-drive"), + "recipe frontmatter should contain '- gws-drive'" + ); + assert!( + fm.contains("- gws-sheets"), + "recipe frontmatter should contain '- gws-sheets'" + ); + } + + #[test] + fn test_helper_skill_frontmatter_uses_block_sequences() { + // Use a service known to have helpers, e.g., drive + let entry = services::SERVICES + .iter() + .find(|s| s.api_name == "drive") + .unwrap(); + + let doc = crate::discovery::RestDescription { + name: entry.api_name.to_string(), + title: Some("Test API".to_string()), + description: Some(entry.description.to_string()), + ..Default::default() + }; + let cli = crate::commands::build_cli(&doc); + let helper = cli + .get_subcommands() + .find(|s| s.get_name().starts_with('+')) + .expect("No helper command found for test"); + + let product_name = product_name_from_title("Test API"); + let md = render_helper_skill( + entry.aliases[0], + helper.get_name(), + helper, + entry, + &product_name, + ); + let fm = extract_frontmatter(&md); + assert_block_style_sequences(fm); + assert!( + fm.contains(&format!("version: {}", env!("CARGO_PKG_VERSION"))), + "helper frontmatter should contain version matching CLI version" + ); + assert!( + fm.contains("bins:\n"), + "frontmatter should contain 'bins:' on its own line" + ); + assert!( + fm.contains("- gws"), + "frontmatter should contain '- gws' block entry" + ); + } } diff --git a/crates/google-workspace-cli/src/helpers/README.md b/crates/google-workspace-cli/src/helpers/README.md new file mode 100644 index 00000000..c08559f9 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/README.md @@ -0,0 +1,95 @@ +# Helper Commands (`+verb`) — Guidelines + +## Design Principle + +The core design of `gws` is **schema-driven**: commands are dynamically generated from Google Discovery Documents at runtime. This avoids maintaining a hardcoded, unbounded argument surface. **Helpers must complement this design, not duplicate it.** + +## When a Helper is Justified + +A `+helper` command should exist only when it provides value that Discovery-based commands **cannot**: + +| Justification | Example | Why Discovery Can't Do It | +|---|---|---| +| **Multi-step orchestration** | `+subscribe` | Creates Pub/Sub topic → subscription → Workspace Events subscription (3 APIs) | +| **Format translation** | `+write` | Transforms Markdown → Docs `batchUpdate` JSON | +| **Multi-API composition** | `+triage` | Lists messages then fetches N metadata payloads concurrently | +| **Complex body construction** | `+send`, `+reply` | Builds RFC 2822 MIME from simple flags | +| **Multipart upload** | `+upload` | Handles resumable upload protocol with progress | +| **Workflow recipes** | `+standup-report` | Chains calls across multiple services | + +**Litmus test:** Can the user achieve the same result with `gws --params '{...}'`? If yes, don't add a helper. + +## Anti-Patterns + +### ❌ Anti-pattern 1: Single API Call Wrapper + +If a helper wraps one API call that Discovery already exposes, reject it. + +**Real example:** `+revisions` (PR #563) wrapped `gws drive files-revisions list` — same single API call, zero added value. + +### ❌ Anti-pattern 2: Unbounded Flag Accumulation + +Adding flags to expose data that is already in the API response creates unbounded surface area. + +**Real example:** `--thread-id`, `--delivered-to`, `--sent-last` on `+triage` (PR #597) — all three values are already present in the Gmail API response. Agents and users should extract them with `--format` or `jq`, not new flags. + +**Why this is harmful:** Every API response contains dozens of fields. If we add a flag for each one, helpers become unbounded maintenance burdens — the exact problem Discovery-driven design solves. + +### ❌ Anti-pattern 3: Duplicating Discovery Parameters + +Don't re-expose Discovery-defined parameters (e.g., `pageSize`, `fields`, `orderBy`) as custom helper flags. Use `--params` passthrough instead. + +## Flag Design Rules + +Helper flags must control **orchestration logic**, not API parameters or output fields. + +### ✅ Good Flags (control orchestration) + +| Flag | Helper | Why It's Good | +|---|---|---| +| `--spreadsheet`, `--range` | `+read` | Identifies which resource to operate on | +| `--to`, `--subject`, `--body` | `+send` | Inputs to MIME construction (format translation) | +| `--dry-run` | `+subscribe` | Controls whether API calls are actually made | +| `--subscription` | `+subscribe` | Switches between "create new" vs. "use existing" orchestration path | +| `--target`, `--project` | `+subscribe` | Required for multi-service resource creation | + +### ❌ Bad Flags (expose API response data) + +| Flag | Why It's Bad | Alternative | +|---|---|---| +| `--thread-id` | Already in API response | `jq '.threadId'` | +| `--delivered-to` | Already in response headers | `jq '.payload.headers[] | ...'` | +| `--include-labels` | Output field filtering | `--format` or `jq` | + +### Decision Checklist for New Flags + +1. Does this flag control **what API call to make** or **how to orchestrate** multiple calls? → ✅ Add it +2. Does this flag control **what data appears in output**? → ❌ Use `--format`/`jq` +3. Does this flag duplicate a Discovery parameter? → ❌ Use `--params` +4. Could the user achieve this with existing flags + post-processing? → ❌ Don't add it + +## Architecture + +Helpers are implemented using the `Helper` trait defined in `mod.rs`. + +- **`inject_commands`**: Adds subcommands to the main service command. All helper commands are always shown regardless of authentication state. +- **`handle`**: Implementation of the command logic. Returns `Ok(true)` if the command was handled, or `Ok(false)` to let the default raw resource handler attempt to handle it. + +## Adding a New Helper — Checklist + +1. **Passes the litmus test** — cannot be done with a single Discovery command +2. **Flags are bounded** — only flags controlling orchestration, not API params/output +3. **Uses shared infrastructure:** + - `crate::client::build_client()` for HTTP + - `crate::validate::validate_resource_name()` for user-supplied resource IDs + - `crate::validate::encode_path_segment()` for URL path segments + - `crate::output::sanitize_for_terminal()` for error messages +4. **Has tests** — at minimum: command registration, required args, happy path +5. **Supports `--dry-run`** where the helper creates or mutates resources + +### Development Steps + +1. Create `src/helpers/.rs` +2. Implement the `Helper` trait +3. Register it in `src/helpers/mod.rs` +4. **Prefix** the command with `+` (e.g., `+create`) diff --git a/src/helpers/calendar.rs b/crates/google-workspace-cli/src/helpers/calendar.rs similarity index 75% rename from src/helpers/calendar.rs rename to crates/google-workspace-cli/src/helpers/calendar.rs index 8ac00a50..cf28b249 100644 --- a/src/helpers/calendar.rs +++ b/crates/google-workspace-cli/src/helpers/calendar.rs @@ -80,14 +80,21 @@ impl Helper for CalendarHelper { .value_name("EMAIL") .action(ArgAction::Append), ) + .arg( + Arg::new("meet") + .long("meet") + .help("Add a Google Meet video conference link") + .action(ArgAction::SetTrue), + ) .after_help("\ EXAMPLES: gws calendar +insert --summary 'Standup' --start '2026-06-17T09:00:00-07:00' --end '2026-06-17T09:30:00-07:00' gws calendar +insert --summary 'Review' --start ... --end ... --attendee alice@example.com + gws calendar +insert --summary 'Meet' --start ... --end ... --meet TIPS: Use RFC3339 format for times (e.g. 2026年06月17日T09:00:00-07:00). - For recurring events or conference links, use the raw API instead."), + The --meet flag automatically adds a Google Meet link to the event."), ); cmd = cmd.subcommand( Command::new("+agenda") @@ -160,7 +167,8 @@ TIPS: let scopes_str: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let (token, auth_method) = match auth::get_token(&scopes_str).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) => return Err(GwsError::Auth(format!("Calendar auth failed: {e}"))), }; let events_res = doc.resources.get("events").ok_or_else(|| { @@ -179,7 +187,6 @@ TIPS: auth_method, None, None, - None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), None, @@ -453,13 +460,60 @@ fn build_insert_request( body["attendees"] = json!(attendees_list); } + let mut params = json!({ + "calendarId": calendar_id + }); + + if matches.get_flag("meet") { + let namespace = uuid::Uuid::NAMESPACE_DNS; + + let mut attendees: Vec<_> = matches + .get_many::("attendee") + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + attendees.sort(); + + let seed_payload = { + let mut map = serde_json::Map::new(); + map.insert("v".to_string(), json!(1)); + map.insert("summary".to_string(), json!(summary)); + map.insert("start".to_string(), json!(start)); + map.insert("end".to_string(), json!(end)); + if let Some(loc) = location { + map.insert("location".to_string(), json!(loc)); + } + if let Some(desc) = description { + map.insert("description".to_string(), json!(desc)); + } + if !attendees.is_empty() { + let attendees_list_for_seed: Vec<_> = attendees + .iter() + .map(|email| json!({ "email": email })) + .collect(); + map.insert("attendees".to_string(), json!(attendees_list_for_seed)); + } + serde_json::Value::Object(map) + }; + + let seed_data = serde_json::to_vec(&seed_payload).map_err(|e| { + GwsError::Other(anyhow::anyhow!( + "Failed to serialize seed payload for idempotency key: {e}" + )) + })?; + let request_id = uuid::Uuid::new_v5(&namespace, &seed_data).to_string(); + + body["conferenceData"] = json!({ + "createRequest": { + "requestId": request_id, + "conferenceSolutionKey": { "type": "hangoutsMeet" } + } + }); + params["conferenceDataVersion"] = json!(1); + } let body_str = body.to_string(); let scopes: Vec = insert_method.scopes.iter().map(|s| s.to_string()).collect(); // events.insert requires 'calendarId' path parameter - let params = json!({ - "calendarId": calendar_id - }); let params_str = params.to_string(); Ok((params_str, body_str, scopes)) @@ -497,7 +551,8 @@ mod tests { Arg::new("attendee") .long("attendee") .action(ArgAction::Append), - ); + ) + .arg(Arg::new("meet").long("meet").action(ArgAction::SetTrue)); cmd.try_get_matches_from(args).unwrap() } @@ -521,6 +576,140 @@ mod tests { assert_eq!(scopes[0], "https://scope"); } + #[test] + fn test_build_insert_request_with_meet() { + let doc = make_mock_doc(); + let matches = make_matches_insert(&[ + "test", + "--summary", + "Meeting", + "--start", + "2024-01-01T10:00:00Z", + "--end", + "2024-01-01T11:00:00Z", + "--meet", + ]); + let (params, body, _) = build_insert_request(&matches, &doc).unwrap(); + + let params_json: serde_json::Value = serde_json::from_str(¶ms).unwrap(); + assert_eq!(params_json["conferenceDataVersion"], 1); + + let body_json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let create_req = &body_json["conferenceData"]["createRequest"]; + assert_eq!(create_req["conferenceSolutionKey"]["type"], "hangoutsMeet"); + assert!(uuid::Uuid::parse_str(create_req["requestId"].as_str().unwrap()).is_ok()); + } + + #[test] + fn test_build_insert_request_with_meet_is_idempotent() { + let doc = make_mock_doc(); + let args = &[ + "test", + "--summary", + "Idempotent Meeting", + "--start", + "2024-01-01T10:00:00Z", + "--end", + "2024-01-01T11:00:00Z", + "--meet", + ]; + let matches1 = make_matches_insert(args); + let (_, body1, _) = build_insert_request(&matches1, &doc).unwrap(); + + let matches2 = make_matches_insert(args); + let (_, body2, _) = build_insert_request(&matches2, &doc).unwrap(); + + let b1: serde_json::Value = serde_json::from_str(&body1).unwrap(); + let b2: serde_json::Value = serde_json::from_str(&body2).unwrap(); + + assert_eq!( + b1["conferenceData"]["createRequest"]["requestId"], + b2["conferenceData"]["createRequest"]["requestId"], + "requestId should be deterministic for the same event details" + ); + } + + #[test] + fn test_build_insert_request_with_meet_idempotency_robust() { + let doc = make_mock_doc(); + + // Base case + let args_base = &[ + "test", + "--summary", + "S", + "--start", + "2024-01-01T10:00:00Z", + "--end", + "2024-01-01T11:00:00Z", + "--meet", + "--attendee", + "a@b.com", + "--attendee", + "c@d.com", + ]; + let (_, body_base, _) = + build_insert_request(&make_matches_insert(args_base), &doc).unwrap(); + let b_base: serde_json::Value = serde_json::from_str(&body_base).unwrap(); + let id_base = b_base["conferenceData"]["createRequest"]["requestId"] + .as_str() + .unwrap(); + + // Same but different attendee order + let args_reordered = &[ + "test", + "--summary", + "S", + "--start", + "2024-01-01T10:00:00Z", + "--end", + "2024-01-01T11:00:00Z", + "--meet", + "--attendee", + "c@d.com", + "--attendee", + "a@b.com", + ]; + let (_, body_reordered, _) = + build_insert_request(&make_matches_insert(args_reordered), &doc).unwrap(); + let b_reordered: serde_json::Value = serde_json::from_str(&body_reordered).unwrap(); + let id_reordered = b_reordered["conferenceData"]["createRequest"]["requestId"] + .as_str() + .unwrap(); + + assert_eq!( + id_base, id_reordered, + "Attendee order should not change requestId" + ); + + // Different summary -> different ID + let args_diff = &[ + "test", + "--summary", + "Diff", + "--start", + "2024-01-01T10:00:00Z", + "--end", + "2024-01-01T11:00:00Z", + "--meet", + "--attendee", + "a@b.com", + "--attendee", + "c@d.com", + ]; + let (_, body_diff, _) = + build_insert_request(&make_matches_insert(args_diff), &doc).unwrap(); + let b_diff: serde_json::Value = serde_json::from_str(&body_diff).unwrap(); + let id_diff = b_diff["conferenceData"]["createRequest"]["requestId"] + .as_str() + .unwrap(); + + assert_ne!( + id_base, id_diff, + "Different summary should produce different requestId" + ); + } + #[test] fn test_build_insert_request_with_optional_fields() { let doc = make_mock_doc(); diff --git a/src/helpers/chat.rs b/crates/google-workspace-cli/src/helpers/chat.rs similarity index 98% rename from src/helpers/chat.rs rename to crates/google-workspace-cli/src/helpers/chat.rs index 94493e53..676dc78b 100644 --- a/src/helpers/chat.rs +++ b/crates/google-workspace-cli/src/helpers/chat.rs @@ -80,7 +80,8 @@ TIPS: let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) => return Err(GwsError::Auth(format!("Chat auth failed: {e}"))), }; // Method: spaces.messages.create @@ -109,7 +110,6 @@ TIPS: auth_method, None, None, - None, matches.get_flag("dry-run"), &pagination, None, diff --git a/src/helpers/docs.rs b/crates/google-workspace-cli/src/helpers/docs.rs similarity index 97% rename from src/helpers/docs.rs rename to crates/google-workspace-cli/src/helpers/docs.rs index 3f6b3896..d3ef7fa2 100644 --- a/src/helpers/docs.rs +++ b/crates/google-workspace-cli/src/helpers/docs.rs @@ -72,7 +72,8 @@ TIPS: let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) => return Err(GwsError::Auth(format!("Docs auth failed: {e}"))), }; // Method: documents.batchUpdate @@ -99,7 +100,6 @@ TIPS: auth_method, None, None, - None, matches.get_flag("dry-run"), &pagination, None, diff --git a/src/helpers/drive.rs b/crates/google-workspace-cli/src/helpers/drive.rs similarity index 94% rename from src/helpers/drive.rs rename to crates/google-workspace-cli/src/helpers/drive.rs index 393e0fde..68662ec6 100644 --- a/src/helpers/drive.rs +++ b/crates/google-workspace-cli/src/helpers/drive.rs @@ -98,7 +98,8 @@ TIPS: let scopes: Vec<&str> = create_method.scopes.iter().map(|s| s.as_str()).collect(); let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) => return Err(GwsError::Auth(format!("Drive auth failed: {e}"))), }; executor::execute_method( @@ -109,8 +110,10 @@ TIPS: token.as_deref(), auth_method, None, - Some(file_path), - None, + Some(executor::UploadSource::File { + path: file_path, + content_type: None, + }), matches.get_flag("dry-run"), &executor::PaginationConfig::default(), None, diff --git a/src/helpers/events/mod.rs b/crates/google-workspace-cli/src/helpers/events/mod.rs similarity index 100% rename from src/helpers/events/mod.rs rename to crates/google-workspace-cli/src/helpers/events/mod.rs diff --git a/src/helpers/events/renew.rs b/crates/google-workspace-cli/src/helpers/events/renew.rs similarity index 86% rename from src/helpers/events/renew.rs rename to crates/google-workspace-cli/src/helpers/events/renew.rs index 3dd1d627..b0f6c821 100644 --- a/src/helpers/events/renew.rs +++ b/crates/google-workspace-cli/src/helpers/events/renew.rs @@ -30,13 +30,43 @@ pub(super) async fn handle_renew( matches: &ArgMatches, ) -> Result<(), GwsError> { let config = parse_renew_args(matches)?; + let dry_run = matches.get_flag("dry-run"); + + if dry_run { + eprintln!("🏃 DRY RUN — no changes will be made\n"); + + // Handle dry-run case and exit early + let result = if let Some(name) = config.name { + let name = crate::validate::validate_resource_name(&name)?; + eprintln!("Reactivating subscription: {name}"); + json!({ + "dry_run": true, + "action": "Would reactivate subscription", + "name": name, + "note": "Run without --dry-run to actually reactivate the subscription" + }) + } else { + json!({ + "dry_run": true, + "action": "Would list and renew subscriptions expiring within", + "within": config.within, + "note": "Run without --dry-run to actually renew subscriptions" + }) + }; + println!( + "{}", + serde_json::to_string_pretty(&result).context("Failed to serialize dry-run output")? + ); + return Ok(()); + } + + // Real run logic let client = crate::client::build_client()?; let ws_token = auth::get_token(&[WORKSPACE_EVENTS_SCOPE]) .await .map_err(|e| GwsError::Auth(format!("Failed to get token: {e}")))?; if let Some(name) = config.name { - // Reactivate a specific subscription let name = crate::validate::validate_resource_name(&name)?; eprintln!("Reactivating subscription: {name}"); let resp = client @@ -51,15 +81,12 @@ pub(super) async fn handle_renew( .context("Failed to reactivate subscription")?; let body: Value = resp.json().await.context("Failed to parse response")?; - println!( "{}", - serde_json::to_string_pretty(&body).unwrap_or_default() + serde_json::to_string_pretty(&body).context("Failed to serialize response body")? ); } else { let within_secs = parse_duration(&config.within)?; - - // List all subscriptions let resp = client .get("https://workspaceevents.googleapis.com/v1/subscriptions") .bearer_auth(&ws_token) @@ -100,7 +127,7 @@ pub(super) async fn handle_renew( }); println!( "{}", - serde_json::to_string_pretty(&result).unwrap_or_default() + serde_json::to_string_pretty(&result).context("Failed to serialize result")? ); } diff --git a/src/helpers/events/subscribe.rs b/crates/google-workspace-cli/src/helpers/events/subscribe.rs similarity index 89% rename from src/helpers/events/subscribe.rs rename to crates/google-workspace-cli/src/helpers/events/subscribe.rs index edbfb4dc..764aeaac 100644 --- a/src/helpers/events/subscribe.rs +++ b/crates/google-workspace-cli/src/helpers/events/subscribe.rs @@ -1,6 +1,7 @@ use super::*; use crate::auth::AccessTokenProvider; use crate::helpers::PUBSUB_API_BASE; +use crate::output::sanitize_for_terminal; use std::path::PathBuf; #[derive(Debug, Clone, Default, Builder)] @@ -106,26 +107,57 @@ pub(super) async fn handle_subscribe( matches: &ArgMatches, ) -> Result<(), GwsError> { let config = parse_subscribe_args(matches)?; + let dry_run = matches.get_flag("dry-run"); + + if dry_run { + eprintln!("🏃 DRY RUN — no changes will be made\n"); + } if let Some(ref dir) = config.output_dir { - std::fs::create_dir_all(dir).context("Failed to create output dir")?; + if !dry_run { + std::fs::create_dir_all(dir).context("Failed to create output dir")?; + } } let client = crate::client::build_client()?; let pubsub_token_provider = auth::token_provider(&[PUBSUB_SCOPE]); - // Get Pub/Sub token - let pubsub_token = auth::get_token(&[PUBSUB_SCOPE]) - .await - .map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?; - let (pubsub_subscription, topic_name, ws_subscription_name, created_resources) = if let Some(ref sub_name) = config.subscription { // Use existing subscription — no setup needed + // (don't fetch Pub/Sub token since we won't need it for existing subscriptions) + if dry_run { + eprintln!("Would listen to existing subscription: {}", sub_name.0); + let result = json!({ + "dry_run": true, + "action": "Would listen to existing subscription", + "subscription": sub_name.0, + "note": "Run without --dry-run to actually start listening" + }); + println!( + "{}", + serde_json::to_string_pretty(&result) + .context("Failed to serialize dry-run output")? + ); + return Ok(()); + } (sub_name.0.clone(), None, None, false) } else { + // Get Pub/Sub token only when creating new subscription + let pubsub_token = if dry_run { + None + } else { + Some( + auth::get_token(&[PUBSUB_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Failed to get Pub/Sub token: {e}")))?, + ) + }; + // Full setup: create Pub/Sub topic + subscription + Workspace Events subscription - let target = config.target.clone().unwrap(); + // Validate target before use in both dry-run and actual execution paths + let target = crate::validate::validate_resource_name(&config.target.clone().unwrap())? + .to_string(); let project = crate::validate::validate_resource_name(&config.project.clone().unwrap().0)? .to_string(); @@ -139,11 +171,43 @@ pub(super) async fn handle_subscribe( let topic = format!("projects/{project}/topics/gws-{slug}-{suffix}"); let sub = format!("projects/{project}/subscriptions/gws-{slug}-{suffix}"); + // Dry-run: print what would be created and exit + if dry_run { + eprintln!("Would create Pub/Sub topic: {topic}"); + eprintln!("Would create Pub/Sub subscription: {sub}"); + eprintln!("Would create Workspace Events subscription for target: {target}"); + eprintln!( + "Would listen for event types: {}", + config.event_types.join(", ") + ); + + let result = json!({ + "dry_run": true, + "action": "Would create Workspace Events subscription", + "pubsub_topic": topic, + "pubsub_subscription": sub, + "target": target, + "event_types": config.event_types, + "note": "Run without --dry-run to actually create subscription" + }); + println!( + "{}", + serde_json::to_string_pretty(&result) + .context("Failed to serialize dry-run output")? + ); + return Ok(()); + } + // 1. Create Pub/Sub topic eprintln!("Creating Pub/Sub topic: {topic}"); + let token = pubsub_token.as_ref().ok_or_else(|| { + GwsError::Auth( + "Token unavailable in non-dry-run mode. This indicates a bug.".to_string(), + ) + })?; let resp = client .put(format!("{PUBSUB_API_BASE}/{topic}")) - .bearer_auth(&pubsub_token) + .bearer_auth(token) .header("Content-Type", "application/json") .body("{}") .send() @@ -168,7 +232,7 @@ pub(super) async fn handle_subscribe( }); let resp = client .put(format!("{PUBSUB_API_BASE}/{sub}")) - .bearer_auth(&pubsub_token) + .bearer_auth(token) .header("Content-Type", "application/json") .json(&sub_body) .send() @@ -344,8 +408,8 @@ async fn pull_loop( Err(e) => return Err(anyhow::anyhow!("Pub/Sub pull failed: {e}").into()), } } - _ = tokio::signal::ctrl_c() => { - eprintln!("\nReceived interrupt, stopping..."); + _ = super::super::shutdown_signal() => { + eprintln!("\nReceived shutdown signal, stopping..."); return Ok(()); } }; @@ -375,7 +439,11 @@ async fn pull_loop( .unwrap_or(0); let path = dir.join(format!("{ts}_{file_counter}.json")); if let Err(e) = std::fs::write(&path, &json_str) { - eprintln!("Warning: failed to write {}: {e}", path.display()); + eprintln!( + "Warning: failed to write {}: {}", + path.display(), + sanitize_for_terminal(&e.to_string()) + ); } else { eprintln!("Wrote {}", path.display()); } @@ -406,11 +474,11 @@ async fn pull_loop( break; } - // Check for SIGINT between polls + // Check for SIGINT/SIGTERM between polls tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, - _ = tokio::signal::ctrl_c() => { - eprintln!("\nReceived interrupt, stopping..."); + _ = super::super::shutdown_signal() => { + eprintln!("\nReceived shutdown signal, stopping..."); break; } } diff --git a/crates/google-workspace-cli/src/helpers/gmail/forward.rs b/crates/google-workspace-cli/src/helpers/gmail/forward.rs new file mode 100644 index 00000000..0c19d4e1 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/gmail/forward.rs @@ -0,0 +1,1001 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +/// Handle the `+forward` subcommand. +pub(super) async fn handle_forward( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let mut config = parse_forward_args(matches)?; + + let dry_run = matches.get_flag("dry-run"); + + let (original, token, client) = if dry_run { + ( + OriginalMessage::dry_run_placeholder(&config.message_id), + None, + None, + ) + } else { + let t = auth::get_token(&[GMAIL_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let c = crate::client::build_client()?; + let orig = fetch_message_metadata(&c, &t, &config.message_id).await?; + config.from = resolve_sender(&c, &t, config.from.as_deref()).await?; + (orig, Some(t), Some(c)) + }; + + // Select which original parts to include: + // - --no-original-attachments: skip regular file attachments, but still + // include inline images in HTML mode (they're part of the body, not + // "attachments" in the UI sense) + // - Plain-text mode: drop inline images entirely (matching Gmail web) + // - HTML mode: include inline images (rendered via cid: in multipart/related) + let mut all_attachments = config.attachments; + if let (Some(client), Some(token)) = (&client, &token) { + let selected: Vec<_> = original + .parts + .iter() + .filter(|p| include_original_part(p, config.html, config.no_original_attachments)) + .cloned() + .collect(); + + fetch_and_merge_original_parts( + client, + token, + &config.message_id, + &selected, + &mut all_attachments, + ) + .await?; + } else { + eprintln!("Note: original attachments not included in dry-run preview"); + } + + let subject = build_forward_subject(&original.subject); + let refs = build_references_chain(&original); + let envelope = ForwardEnvelope { + to: &config.to, + cc: config.cc.as_deref(), + bcc: config.bcc.as_deref(), + from: config.from.as_deref(), + subject: &subject, + body: config.body.as_deref(), + html: config.html, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + + let raw = create_forward_raw_message(&envelope, &original, &all_attachments)?; + + super::dispatch_raw_email( + doc, + matches, + &raw, + original.thread_id.as_deref(), + token.as_deref(), + ) + .await +} + +/// Whether an original MIME part should be included when forwarding. +/// +/// - Regular attachments are included unless `--no-original-attachments` is set. +/// - Inline images are included only in HTML mode (matching Gmail web, which +/// strips them from plain-text forwards). +fn include_original_part(part: &OriginalPart, html: bool, no_original_attachments: bool) -> bool { + if no_original_attachments && !part.is_inline() { + return false; // skip regular attachments when flag is set + } + if !html && part.is_inline() { + return false; // skip inline images in plain-text mode + } + true +} + +// --- Data structures --- + +pub(super) struct ForwardConfig { + pub message_id: String, + pub to: Vec, + pub from: Option>, + pub cc: Option>, + pub bcc: Option>, + pub body: Option, + pub html: bool, + pub attachments: Vec, + pub no_original_attachments: bool, +} + +struct ForwardEnvelope<'a> { + to: &'a [Mailbox], + cc: Option<&'a [Mailbox]>, + bcc: Option<&'a [Mailbox]>, + from: Option<&'a [Mailbox]>, + subject: &'a str, + body: Option<&'a str>, // Optional user note above forwarded block + html: bool, // When true, body and forwarded block are treated as HTML + threading: ThreadingHeaders<'a>, +} + +// --- Message construction --- + +fn build_forward_subject(original_subject: &str) -> String { + if original_subject.to_lowercase().starts_with("fwd:") { + original_subject.to_string() + } else { + format!("Fwd: {}", original_subject) + } +} + +fn create_forward_raw_message( + envelope: &ForwardEnvelope, + original: &OriginalMessage, + attachments: &[Attachment], +) -> Result { + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(envelope.to)) + .subject(envelope.subject); + + let mb = apply_optional_headers(mb, envelope.from, envelope.cc, envelope.bcc); + let mb = set_threading_headers(mb, &envelope.threading); + + let (forwarded_block, separator) = if envelope.html { + (format_forwarded_message_html(original), "
\r\n") + } else { + (format_forwarded_message(original), "\r\n\r\n") + }; + let body = match envelope.body { + Some(note) => format!("{}{}{}", note, separator, forwarded_block), + None => forwarded_block, + }; + + finalize_message(mb, body, envelope.html, attachments) +} + +/// Join mailboxes into a comma-separated Display string. +fn join_mailboxes(mailboxes: &[Mailbox]) -> String { + mailboxes + .iter() + .map(|m| m.to_string()) + .collect::>() + .join(", ") +} + +fn format_forwarded_message(original: &OriginalMessage) -> String { + let to_str = join_mailboxes(&original.to); + let date_line = original + .date + .as_deref() + .map(|d| format!("Date: {}\r\n", d)) + .unwrap_or_default(); + let cc_line = original + .cc + .as_ref() + .map(|cc| format!("Cc: {}\r\n", join_mailboxes(cc))) + .unwrap_or_default(); + + format!( + "---------- Forwarded message ---------\r\n\ + From: {}\r\n\ + {}\ + Subject: {}\r\n\ + To: {}\r\n\ + {}\r\n\ + {}", + original.from, date_line, original.subject, to_str, cc_line, original.body_text + ) +} + +fn format_forwarded_message_html(original: &OriginalMessage) -> String { + let cc_line = match &original.cc { + Some(cc) => format!("Cc: {}
", format_address_list_with_links(cc)), + None => String::new(), + }; + + let body = resolve_html_body(original); + let date_line = match &original.date { + Some(d) => format!("Date: {}
", format_date_for_attribution(d)), + None => String::new(), + }; + let from = format_forward_from(&original.from); + let to = format_address_list_with_links(&original.to); + + format!( + "
\ +
\ + ---------- Forwarded message ---------
\ + From: {}
\ + {}\ + Subject: {}
\ + To: {}
\ + {}\ +
\ +

\ + {}\ +
", + from, + date_line, + html_escape(&original.subject), + to, + cc_line, + body, + ) +} + +// --- Argument parsing --- + +fn parse_forward_args(matches: &ArgMatches) -> Result { + let to = Mailbox::parse_list(matches.get_one::("to").unwrap()); + if to.is_empty() { + return Err(GwsError::Validation( + "--to must specify at least one recipient".to_string(), + )); + } + Ok(ForwardConfig { + message_id: matches.get_one::("message-id").unwrap().to_string(), + to, + from: parse_optional_mailboxes(matches, "from"), + cc: parse_optional_mailboxes(matches, "cc"), + bcc: parse_optional_mailboxes(matches, "bcc"), + body: parse_optional_trimmed(matches, "body"), + html: matches.get_flag("html"), + attachments: parse_attachments(matches)?, + no_original_attachments: matches.get_flag("no-original-attachments"), + }) +} + +#[cfg(test)] +mod tests { + use super::super::tests::{extract_header, strip_qp_soft_breaks}; + use super::*; + + // --- format_forwarded_message (plain text) --- + + #[test] + fn test_format_forwarded_message() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "Original content".to_string(), + ..Default::default() + }; + let msg = format_forwarded_message(&original); + assert!(msg.contains("---------- Forwarded message ---------")); + assert!(msg.contains("From: alice@example.com")); + assert!(msg.contains("Date: Mon, 1 Jan 2026")); + assert!(msg.contains("Subject: Hello")); + assert!(msg.contains("To: bob@example.com")); + assert!(msg.contains("Original content")); + } + + #[test] + fn test_format_forwarded_message_missing_date() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + body_text: "Content".to_string(), + ..Default::default() + }; + let msg = format_forwarded_message(&original); + // Date line should be omitted entirely when absent + assert!(!msg.contains("Date:")); + // Other lines should still be present + assert!(msg.contains("From: alice@example.com")); + assert!(msg.contains("Subject: Hello")); + } + + #[test] + fn test_format_forwarded_message_with_cc() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![ + Mailbox::parse("carol@example.com"), + Mailbox::parse("dave@example.com"), + ]), + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "Content".to_string(), + ..Default::default() + }; + let msg = format_forwarded_message(&original); + assert!(msg.contains("Cc: carol@example.com, dave@example.com")); + + // Without CC, no Cc line + let no_cc = OriginalMessage { + cc: None, + ..original + }; + let msg = format_forwarded_message(&no_cc); + assert!(!msg.contains("Cc:")); + } + + // --- forward subject --- + + #[test] + fn test_build_forward_subject_without_prefix() { + assert_eq!(build_forward_subject("Hello"), "Fwd: Hello"); + } + + #[test] + fn test_build_forward_subject_with_prefix() { + assert_eq!(build_forward_subject("Fwd: Hello"), "Fwd: Hello"); + } + + #[test] + fn test_build_forward_subject_case_insensitive() { + assert_eq!(build_forward_subject("FWD: Hello"), "FWD: Hello"); + } + + #[test] + fn test_create_forward_raw_message_without_body() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original content".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: None, + html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("dave@example.com")); + assert!(extract_header(&raw, "Subject") + .unwrap() + .contains("Fwd: Hello")); + assert!(extract_header(&raw, "In-Reply-To") + .unwrap() + .contains("abc@example.com")); + assert!(raw.contains("---------- Forwarded message ---------")); + assert!(raw.contains("From: alice@example.com")); + assert!(raw.contains("Original content")); + } + + #[test] + fn test_create_forward_raw_message_with_all_optional_headers() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![Mailbox::parse("carol@example.com")]), + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original content".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let cc = Mailbox::parse_list("eve@example.com"); + let bcc = Mailbox::parse_list("secret@example.com"); + let from = Mailbox::parse_list("alias@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: Some(&cc), + bcc: Some(&bcc), + from: Some(&from), + subject: "Fwd: Hello", + body: Some("FYI see below"), + html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("dave@example.com")); + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("eve@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("secret@example.com")); + assert!(extract_header(&raw, "From") + .unwrap() + .contains("alias@example.com")); + assert!(raw.contains("FYI see below")); + assert!(raw.contains("carol@example.com")); // in forwarded block + } + + #[test] + fn test_create_forward_raw_message_references_chain() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "msg-2@example.com".to_string(), + references: vec![ + "msg-0@example.com".to_string(), + "msg-1@example.com".to_string(), + ], + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original content".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: None, + html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap(); + + // All three message IDs should appear in the References header + let refs_header = extract_header(&raw, "References").unwrap(); + assert!(refs_header.contains("msg-0@example.com")); + assert!(refs_header.contains("msg-1@example.com")); + assert!(refs_header.contains("msg-2@example.com")); + // In-Reply-To should have only the direct parent + assert!(extract_header(&raw, "In-Reply-To") + .unwrap() + .contains("msg-2@example.com")); + } + + fn make_forward_matches(args: &[&str]) -> ArgMatches { + let cmd = Command::new("test") + .arg(Arg::new("message-id").long("message-id")) + .arg(Arg::new("to").long("to")) + .arg(Arg::new("from").long("from")) + .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")) + .arg(Arg::new("body").long("body")) + .arg(Arg::new("html").long("html").action(ArgAction::SetTrue)) + .arg( + Arg::new("attach") + .short('a') + .long("attach") + .action(ArgAction::Append), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("no-original-attachments") + .long("no-original-attachments") + .action(ArgAction::SetTrue), + ) + .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue)); + cmd.try_get_matches_from(args).unwrap() + } + + #[test] + fn test_parse_forward_args() { + let matches = + make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]); + let config = parse_forward_args(&matches).unwrap(); + assert_eq!(config.message_id, "abc123"); + assert_eq!(config.to[0].email, "dave@example.com"); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); + assert!(config.body.is_none()); + assert!(!config.no_original_attachments); + } + + #[test] + fn test_parse_forward_args_no_original_attachments() { + let matches = make_forward_matches(&[ + "test", + "--message-id", + "abc123", + "--to", + "dave@example.com", + "--no-original-attachments", + ]); + let config = parse_forward_args(&matches).unwrap(); + assert!(config.no_original_attachments); + } + + #[test] + fn test_parse_forward_args_with_all_options() { + let matches = make_forward_matches(&[ + "test", + "--message-id", + "abc123", + "--to", + "dave@example.com", + "--from", + "alias@example.com", + "--cc", + "eve@example.com", + "--bcc", + "secret@example.com", + "--body", + "FYI", + ]); + let config = parse_forward_args(&matches).unwrap(); + assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com"); + assert_eq!(config.cc.as_ref().unwrap()[0].email, "eve@example.com"); + assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com"); + assert_eq!(config.body.unwrap(), "FYI"); + + // Whitespace-only values become None + let matches = make_forward_matches(&[ + "test", + "--message-id", + "abc123", + "--to", + "dave@example.com", + "--cc", + "", + "--bcc", + " ", + ]); + let config = parse_forward_args(&matches).unwrap(); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); + } + + #[test] + fn test_parse_forward_args_html_flag() { + let matches = make_forward_matches(&[ + "test", + "--message-id", + "abc123", + "--to", + "dave@example.com", + "--html", + ]); + let config = parse_forward_args(&matches).unwrap(); + assert!(config.html); + + // Default is false + let matches = + make_forward_matches(&["test", "--message-id", "abc123", "--to", "dave@example.com"]); + let config = parse_forward_args(&matches).unwrap(); + assert!(!config.html); + } + + #[test] + fn test_parse_forward_args_empty_to_returns_error() { + let matches = make_forward_matches(&["test", "--message-id", "abc123", "--to", ""]); + let err = parse_forward_args(&matches).err().unwrap(); + assert!( + err.to_string().contains("--to"), + "error should mention --to" + ); + } + + // --- HTML mode tests --- + + #[test] + fn test_format_forwarded_message_html_with_html_body() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "plain fallback".to_string(), + body_html: Some("

Rich content

".to_string()), + ..Default::default() + }; + let html = format_forwarded_message_html(&original); + assert!(html.contains("gmail_quote")); + assert!(html.contains("Forwarded message")); + assert!(html.contains("

Rich content

")); + assert!(!html.contains("plain fallback")); + // No blockquote in forwards (unlike replies) + assert!(!html.contains("\nLine two".to_string(), + ..Default::default() + }; + let html = format_forwarded_message_html(&original); + assert!(html.contains("Line one & <stuff>
")); + assert!(html.contains("Line two")); + } + + #[test] + fn test_format_forwarded_message_html_escapes_metadata() { + let original = OriginalMessage { + from: Mailbox::parse("Tom & Jerry "), + to: vec![Mailbox::parse("")], + subject: "A < B & C".to_string(), + date: Some("Jan 1 <2026>".to_string()), + body_text: "text".to_string(), + ..Default::default() + }; + let html = format_forwarded_message_html(&original); + // From line: display name in , email in mailto link + assert!(html.contains("Tom & Jerry")); + assert!(html.contains("tj@example.com")); + // To line: email wrapped in mailto link + assert!(html.contains("")); + assert!(html.contains("A < B & C")); + // Non-RFC-2822 date falls back to html-escaped raw string + assert!(html.contains("Jan 1 <2026>")); + } + + #[test] + fn test_format_forwarded_message_html_conditional_cc() { + let with_cc = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![Mailbox::parse("carol@example.com")]), + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "text".to_string(), + ..Default::default() + }; + let html = format_forwarded_message_html(&with_cc); + assert!(html.contains("Cc: carol@example.com")); + + let without_cc = OriginalMessage { + cc: None, + ..with_cc + }; + let html = format_forwarded_message_html(&without_cc); + assert!(!html.contains("Cc:")); + } + + #[test] + fn test_create_forward_raw_message_html_without_body() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original content".to_string(), + body_html: Some("

Original

".to_string()), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: None, + html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("dave@example.com")); + assert!(decoded.contains("gmail_quote")); + assert!(decoded.contains("Forwarded message")); + assert!(decoded.contains("

Original

")); + } + + #[test] + fn test_create_forward_raw_message_html_plain_text_fallback() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Plain & simple".to_string(), + ..Default::default() + }; + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: Some("

FYI

"), + html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap(); + + let decoded = strip_qp_soft_breaks(&raw); + assert!(decoded.contains("text/html")); + assert!(decoded.contains("

FYI

")); + // Plain text body is HTML-escaped in the fallback + assert!(decoded.contains("Plain & simple")); + } + + #[test] + fn test_create_forward_raw_message_html() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original content".to_string(), + body_html: Some("

Original

".to_string()), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: Some("

FYI

"), + html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + let raw = create_forward_raw_message(&envelope, &original, &[]).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(decoded.contains("

FYI

")); + assert!(decoded.contains("gmail_quote")); + assert!(decoded.contains("Forwarded message")); + assert!(decoded.contains("

Original

")); + } + + #[test] + fn test_create_forward_raw_message_with_attachment() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original content".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Hello", + body: Some("FYI, see attached"), + html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + let attachments = vec![Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf".to_vec(), + content_id: None, + }]; + let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap(); + + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("report.pdf")); + assert!(raw.contains("FYI, see attached")); + assert!(raw.contains("Forwarded message")); + } + + #[test] + fn test_create_forward_raw_message_html_with_inline_image() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Photo".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "See photo".to_string(), + body_html: Some("

See

".to_string()), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Photo", + body: None, + html: true, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + // Simulate original inline image + regular attachment + let attachments = vec![ + Attachment { + filename: "baby.jpg".to_string(), + content_type: "image/jpeg".to_string(), + data: b"fake jpeg".to_vec(), + content_id: Some("baby@example.com".to_string()), + }, + Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf".to_vec(), + content_id: None, + }, + ]; + let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap(); + + // Should have multipart/mixed> multipart/related + attachment + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(raw.contains("report.pdf")); + } + + #[test] + fn test_create_forward_raw_message_plain_text_no_inline_images() { + // In plain-text mode, inline images are filtered out upstream by the + // handler (matching Gmail web, which strips them entirely). Only regular + // attachments reach create_forward_raw_message. + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Photo".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "See photo".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = Mailbox::parse_list("dave@example.com"); + let envelope = ForwardEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Fwd: Photo", + body: None, + html: false, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + }; + // Only regular attachment — inline images are filtered out by the handler + let attachments = vec![Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf".to_vec(), + content_id: None, + }]; + let raw = create_forward_raw_message(&envelope, &original, &attachments).unwrap(); + + assert!(!raw.contains("multipart/related")); + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("report.pdf")); + // No inline images in plain-text forward + assert!(!raw.contains("Content-ID")); + } + + // --- include_original_part filter matrix --- + + fn make_part(inline: bool) -> OriginalPart { + OriginalPart { + filename: "test".to_string(), + content_type: "image/png".to_string(), + size: 100, + attachment_id: "ATT1".to_string(), + content_id: if inline { + Some("cid@example.com".to_string()) + } else { + None + }, + } + } + + #[test] + fn test_include_original_part_default_html_includes_all() { + let regular = make_part(false); + let inline = make_part(true); + assert!(include_original_part(®ular, true, false)); + assert!(include_original_part(&inline, true, false)); + } + + #[test] + fn test_include_original_part_default_plain_drops_inline() { + let regular = make_part(false); + let inline = make_part(true); + assert!(include_original_part(®ular, false, false)); + assert!(!include_original_part(&inline, false, false)); + } + + #[test] + fn test_include_original_part_no_attachments_html_keeps_inline() { + let regular = make_part(false); + let inline = make_part(true); + // Key behavior: --no-original-attachments skips files but keeps inline images + assert!(!include_original_part(®ular, true, true)); + assert!(include_original_part(&inline, true, true)); + } + + #[test] + fn test_include_original_part_no_attachments_plain_drops_everything() { + let regular = make_part(false); + let inline = make_part(true); + assert!(!include_original_part(®ular, false, true)); + assert!(!include_original_part(&inline, false, true)); + } +} diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs new file mode 100644 index 00000000..caeb8b6b --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -0,0 +1,3982 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::Helper; +pub mod forward; +pub mod read; +pub mod reply; +pub mod send; +pub mod triage; +pub mod watch; + +use forward::handle_forward; +use read::handle_read; +use reply::handle_reply; +use send::handle_send; +use triage::handle_triage; +use watch::handle_watch; + +pub(super) use crate::auth; +pub(super) use crate::error::GwsError; +pub(super) use crate::executor; +use crate::output::sanitize_for_terminal; +pub(super) use anyhow::Context; +pub(super) use base64::{engine::general_purpose::URL_SAFE, Engine as _}; +pub(super) use clap::{Arg, ArgAction, ArgMatches, Command}; +pub(super) use mail_builder::headers::address::Address as MbAddress; +pub(super) use serde::Serialize; +pub(super) use serde_json::{json, Value}; +use std::future::Future; +use std::pin::Pin; + +pub struct GmailHelper; + +/// Broad scope used by reply/forward handlers for both message metadata +/// fetching and the final send/draft operation. Covers `messages.send`, +/// `drafts.create`, and read access in a single token. +pub(super) const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.modify"; +pub(super) const GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly"; +pub(super) const PUBSUB_SCOPE: &str = "https://www.googleapis.com/auth/pubsub"; + +/// Strip ASCII control characters (0x00–0x1F, 0x7F) from a string. +/// +/// Defense-in-depth: mail-builder uses structured types for headers which +/// prevents most injection, but email addresses are written as raw bytes +/// inside angle brackets. Stripping control characters at the parse boundary +/// closes any residual CRLF/null-byte injection vectors before data reaches +/// mail-builder. +fn sanitize_control_chars(s: &str) -> String { + s.chars().filter(|c| !c.is_ascii_control()).collect() +} + +/// A parsed RFC 5322 mailbox: optional display name + email address. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub(super) struct Mailbox { + pub name: Option, + pub email: String, +} + +impl Mailbox { + /// Parse a single address like `"Alice "` or `"alice@example.com"`. + /// + /// Intentionally total (never fails): this parses both user CLI input and + /// Gmail API header values. API headers are already server-validated, so + /// returning `Result` would force unnecessary error handling at every parse site. + /// User-input validation happens at the `Config` boundary (non-empty `--to`); + /// syntactic email validation is left to the Gmail API. + pub fn parse(raw: &str) -> Self { + let raw = raw.trim(); + if let Some(start) = raw.rfind('<') { + if let Some(end) = raw[start..].find('>') { + let email = sanitize_control_chars(raw[start + 1..start + end].trim()); + let name_part = raw[..start].trim(); + let name = if name_part.is_empty() { + None + } else { + // Strip surrounding quotes: "Alice Smith" → Alice Smith + let unquoted = name_part + .strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(name_part); + Some(sanitize_control_chars(unquoted)) + }; + return Self { name, email }; + } + } + Self { + name: None, + email: sanitize_control_chars(raw), + } + } + + /// Parse a comma-separated address list, respecting quoted strings. + /// Empty-email entries (e.g. from trailing commas) are filtered out. + pub fn parse_list(raw: &str) -> Vec { + split_raw_mailbox_list(raw) + .into_iter() + .map(Mailbox::parse) + .filter(|m| !m.email.is_empty()) + .collect() + } + + /// Lowercase email for case-insensitive comparison. + pub fn email_lowercase(&self) -> String { + self.email.to_lowercase() + } +} + +/// Display format for logging and plain-text message bodies (not RFC 5322 headers). +/// Does not quote display names containing specials; mail-builder handles header serialization. +impl std::fmt::Display for Mailbox { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match &self.name { + Some(name) => write!(f, "{} <{}>", name, self.email), + None => write!(f, "{}", self.email), + } + } +} + +/// Convert a single `Mailbox` to a `mail_builder::Address`. +pub(super) fn to_mb_address(mailbox: &Mailbox) -> MbAddress<'_> { + MbAddress::new_address(mailbox.name.as_deref(), &mailbox.email) +} + +/// Convert a slice of `Mailbox` to a `mail_builder::Address` (list). +pub(super) fn to_mb_address_list(mailboxes: &[Mailbox]) -> MbAddress<'_> { + MbAddress::new_list(mailboxes.iter().map(to_mb_address).collect()) +} + +/// Strip angle brackets from a message ID: `""` → `"abc@example.com"`. +pub(super) fn strip_angle_brackets(id: &str) -> &str { + id.trim() + .strip_prefix('<') + .and_then(|s| s.strip_suffix('>')) + .unwrap_or(id.trim()) +} + +/// Metadata for an attachment or inline image from the original message's MIME payload. +/// +/// Binary data is NOT stored here — it is fetched separately via `fetch_original_parts` +/// after the metadata parse, using the `attachment_id`. +#[derive(Debug, Clone)] +pub(super) struct OriginalPart { + /// Filename from the MIME part. Synthesized as `"part-{index}.{ext}"` when absent. + pub filename: String, + /// MIME content type (e.g., `"image/png"`, `"application/pdf"`). + pub content_type: String, + /// Size in bytes from the Gmail API `body.size` field. + pub size: u64, + /// Gmail API attachment ID for fetching binary data. + pub attachment_id: String, + /// Content-ID for inline images (bare, no angle brackets). + /// When present, the part is an inline image referenced via `cid:` URLs in the HTML body. + /// When absent, the part is a regular file attachment. + pub content_id: Option, +} + +impl OriginalPart { + /// Whether this part is an inline image (has a Content-ID and is not explicitly + /// `Content-Disposition: attachment`) vs a regular file attachment. + pub fn is_inline(&self) -> bool { + self.content_id.is_some() + } +} + +/// A parsed Gmail message fetched via the API, used as context for reply/forward. +/// +/// `from` is always populated — `parse_original_message` returns an error when +/// `From` is missing. `body_text` always has a value — it falls back to the +/// message snippet when no `text/plain` MIME part is found. Semantically optional +/// fields (`cc`, `reply_to`, `date`, `body_html`) use `Option` so the compiler +/// enforces absence checks. +#[derive(Default, Serialize)] +pub(super) struct OriginalMessage { + pub thread_id: Option, + /// Bare message ID (no angle brackets), e.g. `"abc@example.com"`. + pub message_id: String, + /// Bare message IDs (no angle brackets) forming the references chain. + pub references: Vec, + pub from: Mailbox, + /// Multiple Reply-To addresses are allowed per RFC 5322. + pub reply_to: Option>, + pub to: Vec, + pub cc: Option>, + pub subject: String, + pub date: Option, + pub body_text: String, + pub body_html: Option, + /// Attachments and inline images from the original MIME payload (metadata only). + /// Binary data is fetched separately via `fetch_original_parts`. + #[serde(skip_serializing)] + pub parts: Vec, +} + +impl OriginalMessage { + /// Placeholder used for `--dry-run` to avoid requiring auth/network. + pub(super) fn dry_run_placeholder(message_id: &str) -> Self { + Self { + thread_id: Some(format!("thread-{message_id}")), + message_id: format!("{message_id}@example.com"), + from: Mailbox::parse("sender@example.com"), + to: vec![Mailbox::parse("you@example.com")], + subject: "Original subject".to_string(), + date: Some("Thu, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original message body".to_string(), + body_html: Some("

Original message body

".to_string()), + ..Default::default() + } + } +} + +/// Raw header values extracted from the Gmail API payload, before parsing into +/// structured types. Intermediate step: JSON headers → this → `OriginalMessage`. +#[derive(Default)] +struct ParsedMessageHeaders { + from: String, + reply_to: String, + to: String, + cc: String, + subject: String, + date: String, + message_id: String, + references: String, +} + +fn append_header_value(existing: &mut String, value: &str) { + if !existing.is_empty() { + existing.push(' '); + } + existing.push_str(value); +} + +fn append_address_list_header_value(existing: &mut String, value: &str) { + if value.is_empty() { + return; + } + + if !existing.is_empty() { + existing.push_str(", "); + } + existing.push_str(value); +} + +fn parse_message_headers(headers: &[Value]) -> ParsedMessageHeaders { + let mut parsed = ParsedMessageHeaders::default(); + + for header in headers { + let name = header.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = header.get("value").and_then(|v| v.as_str()).unwrap_or(""); + + match name { + "From" => parsed.from = value.to_string(), + "Reply-To" => append_address_list_header_value(&mut parsed.reply_to, value), + "To" => append_address_list_header_value(&mut parsed.to, value), + "Cc" => append_address_list_header_value(&mut parsed.cc, value), + "Subject" => parsed.subject = value.to_string(), + "Date" => parsed.date = value.to_string(), + "Message-ID" | "Message-Id" => parsed.message_id = value.to_string(), + "References" => append_header_value(&mut parsed.references, value), + _ => {} + } + } + + parsed +} + +/// Convert an empty string to `None`, or apply `f` to the non-empty string. +fn non_empty_then(s: &str, f: impl FnOnce(&str) -> T) -> Option { + if s.is_empty() { + None + } else { + Some(f(s)) + } +} + +/// Convert an empty slice to `None`, non-empty to `Some(slice)`. +pub(super) fn non_empty_slice(s: &[T]) -> Option<&[t]> { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn parse_original_message(msg: &Value) -> Result { + let thread_id = msg + .get("threadId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from); + + let snippet = msg + .get("snippet") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let parsed_headers = msg + .get("payload") + .and_then(|p| p.get("headers")) + .and_then(|h| h.as_array()) + .map(|headers| parse_message_headers(headers)) + .unwrap_or_default(); + + if parsed_headers.from.is_empty() { + return Err(GwsError::Other(anyhow::anyhow!( + "Message is missing From header" + ))); + } + + let message_id = strip_angle_brackets(&parsed_headers.message_id); + if message_id.is_empty() { + return Err(GwsError::Other(anyhow::anyhow!( + "Message is missing Message-ID header" + ))); + } + + let PayloadContents { + body_text: extracted_text, + body_html, + parts: original_parts, + } = msg + .get("payload") + .map(extract_payload_contents) + .unwrap_or_default(); + + let body_text = extracted_text.unwrap_or(snippet); + + // Parse references: split on whitespace and strip any angle brackets, producing bare IDs + let references = parsed_headers + .references + .split_whitespace() + .map(|id| strip_angle_brackets(id).to_string()) + .filter(|id| !id.is_empty()) + .collect(); + + let reply_to = non_empty_then(&parsed_headers.reply_to, Mailbox::parse_list); + let cc = non_empty_then(&parsed_headers.cc, Mailbox::parse_list); + let date = Some(parsed_headers.date).filter(|s| !s.is_empty()); + + Ok(OriginalMessage { + thread_id, + message_id: message_id.to_string(), + references, + from: Mailbox::parse(&parsed_headers.from), + reply_to, + to: Mailbox::parse_list(&parsed_headers.to), + cc, + subject: parsed_headers.subject, + date, + body_text, + body_html, + parts: original_parts, + }) +} + +pub(super) async fn fetch_message_metadata( + client: &reqwest::Client, + token: &str, + message_id: &str, +) -> Result { + let url = format!( + "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}", + crate::validate::encode_path_segment(message_id) + ); + + let resp = crate::client::send_with_retry(|| { + client + .get(&url) + .bearer_auth(token) + .query(&[("format", "full")]) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch message: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "(error body unreadable)".to_string()); + return Err(build_api_error( + status, + &body, + &format!("Failed to fetch message {message_id}"), + )); + } + + let msg: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse message: {e}")))?; + + parse_original_message(&msg) +} + +/// Build a `GwsError::Api` from an HTTP error response body, parsing the +/// Google JSON error format when possible. Modeled after the executor's +/// `handle_error_response`, extracting message, reason, and enable URL. +pub(super) fn build_api_error(status: u16, body: &str, context: &str) -> GwsError { + let err_json: Option = serde_json::from_str(body).ok(); + let err_obj = err_json.as_ref().and_then(|v| v.get("error")); + let message = err_obj + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or(body) + .to_string(); + let reason = err_obj + .and_then(|e| e.get("errors")) + .and_then(|e| e.as_array()) + .and_then(|arr| arr.first()) + .and_then(|e| e.get("reason")) + .and_then(|r| r.as_str()) + .or_else(|| { + err_obj + .and_then(|e| e.get("reason")) + .and_then(|r| r.as_str()) + }) + .unwrap_or("unknown") + .to_string(); + let enable_url = if reason == "accessNotConfigured" { + crate::executor::extract_enable_url(&message) + } else { + None + }; + GwsError::Api { + code: status, + message: format!("{context}: {message}"), + reason, + enable_url, + } +} + +#[derive(Debug)] +struct SendAsIdentity { + mailbox: Mailbox, + is_default: bool, +} + +/// Fetch all send-as identities from the Gmail settings API. +async fn fetch_send_as_identities( + client: &reqwest::Client, + token: &str, +) -> Result, GwsError> { + let resp = crate::client::send_with_retry(|| { + client + .get("https://gmail.googleapis.com/gmail/v1/users/me/settings/sendAs") + .bearer_auth(token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch sendAs settings: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "(error body unreadable)".to_string()); + return Err(build_api_error( + status, + &body, + "Failed to fetch sendAs settings", + )); + } + + let body: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse sendAs response: {e}")))?; + + Ok(parse_send_as_response(&body)) +} + +/// Parse the JSON response from the sendAs.list endpoint into identities. +fn parse_send_as_response(body: &Value) -> Vec { + let empty = vec![]; + let entries = body + .get("sendAs") + .and_then(|v| v.as_array()) + .unwrap_or(&empty); + + entries + .iter() + .filter_map(|entry| { + let email = entry.get("sendAsEmail")?.as_str()?; + let display_name = entry + .get("displayName") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()); + // Build a formatted address string so Mailbox::parse applies + // sanitize_control_chars, consistent with all other Mailbox creation paths. + let raw = match display_name { + Some(name) => format!("{name} <{email}>"), + None => email.to_string(), + }; + let is_default = entry + .get("isDefault") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Some(SendAsIdentity { + mailbox: Mailbox::parse(&raw), + is_default, + }) + }) + .collect() +} + +/// Given pre-fetched send-as identities, resolve the `From` address. +/// +/// - `from` is `None` → returns the default send-as identity (or `None` if +/// no default exists in the list) +/// - `from` has bare emails → enriches with send-as display names (mailboxes +/// that already have a display name pass through unchanged) +fn resolve_sender_from_identities( + from: Option<&[mailbox]>, + identities: &[SendAsIdentity], +) -> Option> { + match from { + // No from provided → use default identity. + None => identities + .iter() + .find(|id| id.is_default) + .map(|id| vec![id.mailbox.clone()]), + // Enrich bare emails (no display name) from the send-as list. + // Mailboxes that already have a display name pass through unchanged. + Some(addrs) => { + let enriched: Vec = addrs + .iter() + .map(|m| { + if m.name.is_some() { + return m.clone(); + } + identities + .iter() + .find(|id| id.mailbox.email.eq_ignore_ascii_case(&m.email)) + .map(|id| id.mailbox.clone()) + .unwrap_or_else(|| m.clone()) + }) + .collect(); + Some(enriched) + } + } +} + +/// Resolve the `From` address using Gmail send-as identities. +/// +/// Fetches send-as settings and enriches the From address with the display name. +/// Degrades gracefully if the API call fails — returns the original `from` +/// addresses unchanged (without display name enrichment), or `Ok(None)` if +/// `from` was not provided. +/// +/// Note: this resolves the *sender identity* for the From header only. Callers +/// that need the authenticated user's *primary* email (e.g. reply-all self-dedup) +/// should fetch it separately via `/users/me/profile`, since the default send-as +/// alias may differ from the primary address. +pub(super) async fn resolve_sender( + client: &reqwest::Client, + token: &str, + from: Option<&[mailbox]>, +) -> Result>, GwsError> { + // All provided mailboxes already have display names — skip API call. + if let Some(addrs) = from { + if addrs.iter().all(|m| m.name.is_some()) { + return Ok(Some(addrs.to_vec())); + } + } + + let identities = match fetch_send_as_identities(client, token).await { + Ok(ids) => ids, + Err(e) => { + let hint = if from.is_some() { + "proceeding with email-only From header" + } else { + "Gmail will use your default address" + }; + eprintln!( + "Note: could not fetch send-as settings ({}); {hint}", + sanitize_for_terminal(&e.to_string()) + ); + return Ok(from.map(|addrs| addrs.to_vec())); + } + }; + + let mut result = resolve_sender_from_identities(from, &identities); + + // When the resolved identity has no display name (common for Workspace accounts + // where the primary address inherits its name from the organization directory), + // try the People API as a fallback. This requires the `profile` scope, which + // may not be granted — if so, degrade gracefully with a hint. + if let Some(ref addrs) = result { + // Only attempt People API for a single address — the API returns one + // profile name, so it can't meaningfully enrich multiple From addresses. + if addrs.len() == 1 && addrs[0].name.is_none() { + let profile_token = + auth::get_token(&["https://www.googleapis.com/auth/userinfo.profile"]).await; + match profile_token { + Err(e) => { + // Token acquisition failed — scope likely not granted. + eprintln!( + "Tip: run `gws auth login` and grant the \"profile\" scope \ + to include your display name in the From header ({})", + sanitize_for_terminal(&e.to_string()) + ); + } + Ok(t) => match fetch_profile_display_name(client, &t).await { + Ok(Some(name)) => { + let raw = format!("{name} <{}>", addrs[0].email); + result = Some(vec![Mailbox::parse(&raw)]); + } + Ok(None) => {} + Err(e) if matches!(&e, GwsError::Api { code: 403, .. }) => { + // Token exists but doesn't carry the scope. + eprintln!( + "Tip: run `gws auth login` and grant the \"profile\" scope \ + to include your display name in the From header" + ); + } + Err(e) => { + eprintln!( + "Note: could not fetch display name from People API ({})", + sanitize_for_terminal(&e.to_string()) + ); + } + }, + } + } + } + + Ok(result) +} + +/// Fetch the authenticated user's display name from the People API. +/// Requires a token with the `profile` scope. +async fn fetch_profile_display_name( + client: &reqwest::Client, + token: &str, +) -> Result, GwsError> { + let resp = crate::client::send_with_retry(|| { + client + .get("https://people.googleapis.com/v1/people/me") + .query(&[("personFields", "names")]) + .bearer_auth(token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("People API request failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "(error body unreadable)".to_string()); + return Err(build_api_error(status, &body, "People API request failed")); + } + + let body: Value = resp.json().await.map_err(|e| { + GwsError::Other(anyhow::anyhow!("Failed to parse People API response: {e}")) + })?; + + Ok(parse_profile_display_name(&body)) +} + +/// Extract the display name from a People API `people.get` response. +fn parse_profile_display_name(body: &Value) -> Option { + body.get("names") + .and_then(|v| v.as_array()) + .and_then(|names| names.first()) + .and_then(|n| n.get("displayName")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(sanitize_control_chars) +} + +/// Fetch binary data for a single attachment from the Gmail API. +/// +/// Calls `GET /users/me/messages/{messageId}/attachments/{attachmentId}`, +/// decodes the base64url `data` field, and returns raw bytes. +async fn fetch_attachment_data( + client: &reqwest::Client, + token: &str, + message_id: &str, + attachment_id: &str, +) -> Result, GwsError> { + let url = format!( + "https://gmail.googleapis.com/gmail/v1/users/me/messages/{}/attachments/{}", + crate::validate::encode_path_segment(message_id), + crate::validate::encode_path_segment(attachment_id), + ); + + let resp = crate::client::send_with_retry(|| client.get(&url).bearer_auth(token)) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch attachment: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let err = resp + .text() + .await + .unwrap_or_else(|_| "(error body unreadable)".to_string()); + return Err(build_api_error( + status, + &err, + &format!("Failed to fetch attachment {attachment_id} from message {message_id}"), + )); + } + + let body: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse attachment JSON: {e}")))?; + + let data_str = body.get("data").and_then(|v| v.as_str()).ok_or_else(|| { + GwsError::Other(anyhow::anyhow!( + "Attachment response missing 'data' field for {attachment_id}" + )) + })?; + + URL_SAFE + .decode(data_str) + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to decode attachment data: {e}"))) +} + +/// Fetch binary data for selected original parts, converting them to `Attachment`s. +/// +/// Performs a size preflight check using metadata before downloading, then fetches +/// parts sequentially. `existing_bytes` is the cumulative size of user-supplied +/// `--attach` files, counted against the combined size limit. +pub(super) async fn fetch_original_parts( + client: &reqwest::Client, + token: &str, + message_id: &str, + parts: &[OriginalPart], + existing_bytes: u64, +) -> Result, GwsError> { + // Size preflight: check metadata sizes before downloading anything + let total_metadata_size: u64 = parts.iter().map(|p| p.size).sum(); + if existing_bytes + total_metadata_size> MAX_TOTAL_ATTACHMENT_BYTES { + return Err(GwsError::Validation(format!( + "Original attachments ({:.1} MB) plus user attachments ({:.1} MB) exceed {}MB limit", + total_metadata_size as f64 / (1024.0 * 1024.0), + existing_bytes as f64 / (1024.0 * 1024.0), + MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024), + ))); + } + + eprintln!( + "Fetching {} original attachment(s) ({:.1} MB)...", + parts.len(), + total_metadata_size as f64 / (1024.0 * 1024.0), + ); + + let mut attachments = Vec::with_capacity(parts.len()); + let mut actual_bytes = existing_bytes; + + for part in parts { + let data = fetch_attachment_data(client, token, message_id, &part.attachment_id).await?; + + actual_bytes += data.len() as u64; + if actual_bytes> MAX_TOTAL_ATTACHMENT_BYTES { + return Err(GwsError::Validation(format!( + "Total attachment size exceeds {}MB limit (after downloading '{}')", + MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024), + part.filename, + ))); + } + + attachments.push(Attachment { + filename: part.filename.clone(), + content_type: part.content_type.clone(), + data, + content_id: part.content_id.clone(), + }); + } + + Ok(attachments) +} + +/// Fetch selected original parts and merge them into an existing attachment list. +/// +/// Shared by `+forward` and `+reply`/`+reply-all` handlers. The caller is +/// responsible for filtering `parts` to the desired subset before calling +/// this function. +pub(super) async fn fetch_and_merge_original_parts( + client: &reqwest::Client, + token: &str, + message_id: &str, + parts: &[OriginalPart], + attachments: &mut Vec, +) -> Result<(), GwsError> { + if parts.is_empty() { + return Ok(()); + } + let user_bytes: u64 = attachments.iter().map(|a| a.data.len() as u64).sum(); + let fetched = fetch_original_parts(client, token, message_id, parts, user_bytes).await?; + attachments.extend(fetched); + Ok(()) +} + +/// Everything extracted from the MIME payload in a single recursive pass: +/// the plain text body, HTML body, and attachment/inline part metadata. +#[derive(Default)] +struct PayloadContents { + body_text: Option, + body_html: Option, + parts: Vec, +} + +/// Decode a base64url-encoded text body part, returning the string on success. +fn decode_text_body(data: &str, mime_label: &str) -> Option { + match URL_SAFE.decode(data) { + Ok(decoded) => match String::from_utf8(decoded) { + Ok(s) => Some(s), + Err(e) => { + eprintln!( + "Warning: {mime_label} body is not valid UTF-8: {}", + sanitize_for_terminal(&e.to_string()) + ); + None + } + }, + Err(e) => { + eprintln!( + "Warning: {mime_label} body has invalid base64: {}", + sanitize_for_terminal(&e.to_string()) + ); + None + } + } +} + +/// Synthesize a filename from the part index and MIME type when no filename is present. +/// e.g., `"image/png"` at index 1 → `"part-1.png"`. +fn synthesize_filename(part_index: usize, mime_type: &str) -> String { + let ext = mime_type + .split('/') + .nth(1) + .map(|sub| match sub { + "jpeg" => "jpg", + "svg+xml" => "svg", + "octet-stream" => "bin", + other => other, + }) + .unwrap_or("bin"); + format!("part-{part_index}.{ext}") +} + +/// Sanitize a remote filename: strip ASCII control characters and fall back to +/// a synthesized name if the result is empty. Unlike `--attach` (where we reject +/// bad paths), remote filenames are sender-controlled and should not fail the operation. +fn sanitize_remote_filename(raw: &str, part_index: usize, mime_type: &str) -> String { + let cleaned: String = raw.chars().filter(|c| !c.is_ascii_control()).collect(); + let cleaned = cleaned.trim(); + if cleaned.is_empty() { + synthesize_filename(part_index, mime_type) + } else { + cleaned.to_string() + } +} + +/// Get a header value from a MIME part's headers array, case-insensitive. +fn get_part_header<'a>(part: &'a Value, name: &str) -> Option<&'a str> { + part.get("headers") + .and_then(|h| h.as_array()) + .and_then(|headers| { + headers.iter().find_map(|h| { + let n = h.get("name").and_then(|v| v.as_str()).unwrap_or(""); + if n.eq_ignore_ascii_case(name) { + h.get("value").and_then(|v| v.as_str()) + } else { + None + } + }) + }) +} + +/// Walk the MIME payload tree in a single pass, collecting the text body, HTML body, +/// and metadata for all attachment/inline parts. +fn extract_payload_contents(payload: &Value) -> PayloadContents { + let mut contents = PayloadContents::default(); + extract_payload_recursive(payload, &mut contents, &mut 0); + contents +} + +fn extract_payload_recursive( + part: &Value, + contents: &mut PayloadContents, + part_counter: &mut usize, +) { + let mime_type = part.get("mimeType").and_then(|v| v.as_str()).unwrap_or(""); + + let filename = part.get("filename").and_then(|v| v.as_str()).unwrap_or(""); + + let body = part.get("body"); + + let attachment_id = body + .and_then(|b| b.get("attachmentId")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let body_data = body.and_then(|b| b.get("data")).and_then(|d| d.as_str()); + + let body_size = body + .and_then(|b| b.get("size")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let content_id_header = get_part_header(part, "Content-ID"); + + // Primary signal: does this part have fetchable binary data? + let is_hydratable = !attachment_id.is_empty(); + + // A body text part has inline body.data, no attachmentId, no filename, and no Content-ID. + let is_body_text_part = + !is_hydratable && filename.is_empty() && content_id_header.is_none() && body_data.is_some(); + + if is_body_text_part { + // body_data is guaranteed Some by the is_body_text_part check above. + let data = body_data.unwrap(); + if mime_type == "text/plain" && contents.body_text.is_none() { + contents.body_text = decode_text_body(data, "text/plain"); + } else if mime_type == "text/html" && contents.body_html.is_none() { + contents.body_html = decode_text_body(data, "text/html"); + } + } else if is_hydratable { + // This part has fetchable data — classify as inline or attachment + let index = *part_counter; + *part_counter += 1; + + // Classify as inline only when Content-ID is present AND + // Content-Disposition is not explicitly "attachment". Gmail gives + // Content-IDs to regular attachments too (e.g., PDFs), so Content-ID + // alone is not sufficient — we must check disposition. + let disposition_header = get_part_header(part, "Content-Disposition"); + let explicitly_attachment = disposition_header + .map(|d| d.to_ascii_lowercase().starts_with("attachment")) + .unwrap_or(false); + + // Sanitize Content-ID: strip angle brackets and control characters. + // Content-ID is sender-controlled; CR/LF could inject MIME headers via + // mail-builder's MessageId, which writes the value raw inside <...>. + // Treat as absent when the part is explicitly an attachment. + let content_id = if explicitly_attachment { + None + } else { + content_id_header + .map(|cid| sanitize_control_chars(strip_angle_brackets(cid))) + .filter(|cid| !cid.is_empty()) + }; + + let resolved_filename = if !filename.is_empty() { + sanitize_remote_filename(filename, index, mime_type) + } else { + synthesize_filename(index, mime_type) + }; + + let sanitized_mime = sanitize_control_chars(mime_type); + contents.parts.push(OriginalPart { + filename: resolved_filename, + content_type: if sanitized_mime.is_empty() { + "application/octet-stream".to_string() + } else { + sanitized_mime + }, + size: body_size, + attachment_id: attachment_id.to_string(), + content_id, + }); + // Do NOT recurse into hydratable parts. A message/rfc822 attachment or + // other encapsulated multipart has its own MIME subtree — recursing would + // incorrectly pull the attached message's body text and nested parts into + // the top-level message. + } else { + // Only recurse into non-hydratable container nodes (multipart/mixed, etc.) + if let Some(child_parts) = part.get("parts").and_then(|p| p.as_array()) { + for child in child_parts { + extract_payload_recursive(child, contents, part_counter); + } + } + } +} + +/// Resolve the HTML body for quoting or forwarding: use the original HTML +/// body if available, otherwise escape the plain text and convert newlines +/// to `
` tags. +pub(super) fn resolve_html_body(original: &OriginalMessage) -> String { + match &original.body_html { + Some(html) => html.clone(), + None => html_escape(&original.body_text) + .lines() + .collect::>() + .join("
\r\n"), + } +} + +/// Escape `&`, `<`, `>`, `"`, `'` for safe embedding in HTML. +pub(super) fn html_escape(text: &str) -> String { + // `&` must be replaced first to avoid double-escaping the other replacements. + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +/// Split an RFC 5322 mailbox list on commas, respecting quoted strings. +/// Returns raw string slices — use `Mailbox::parse_list` for structured parsing. +fn split_raw_mailbox_list(header: &str) -> Vec<&str> { + let mut result = Vec::new(); + let mut in_quotes = false; + let mut start = 0; + let mut prev_backslash = false; + + for (i, ch) in header.char_indices() { + match ch { + '\\' if in_quotes => { + prev_backslash = !prev_backslash; + continue; + } + '"' if !prev_backslash => in_quotes = !in_quotes, + ',' if !in_quotes => { + let token = header[start..i].trim(); + if !token.is_empty() { + result.push(token); + } + start = i + 1; + } + _ => {} + } + prev_backslash = false; + } + + let token = header[start..].trim(); + if !token.is_empty() { + result.push(token); + } + + result +} + +/// Wrap an email address in an HTML mailto link: `e`. +/// +/// The email is percent-encoded in the href to prevent mailto parameter +/// injection (e.g., `?cc=evil@example.com`) and HTML-escaped in the display text. +pub(super) fn format_email_link(email: &str) -> String { + use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; + let url_encoded = utf8_percent_encode(email, NON_ALPHANUMERIC); + let display_escaped = html_escape(email); + format!("{display_escaped}") +} + +/// Format a `Mailbox` for the reply attribution line with a mailto link. +/// `Mailbox { name: Some("Alice"), email: "alice@example.com" }` → +/// `Alice <alice@example.com>` +pub(super) fn format_sender_for_attribution(mailbox: &Mailbox) -> String { + match &mailbox.name { + Some(name) => format!( + "{} <{}>", + html_escape(name), + format_email_link(&mailbox.email), + ), + None => format_email_link(&mailbox.email), + } +} + +/// Format a slice of mailboxes with mailto links on each address. +/// Used for forward To/CC fields in HTML mode. +pub(super) fn format_address_list_with_links(mailboxes: &[Mailbox]) -> String { + mailboxes + .iter() + .map(format_sender_for_attribution) + .collect::>() + .join(", ") +} + +/// Reformat an RFC 2822 date to Gmail's human-friendly attribution style: +/// `"Wed, Mar 4, 2026 at 3:01\u{202f}PM"` (`\u{202f}` = narrow no-break space +/// before AM/PM). Falls back to the raw date (HTML-escaped) if chrono cannot +/// parse it. +pub(super) fn format_date_for_attribution(raw_date: &str) -> String { + chrono::DateTime::parse_from_rfc2822(raw_date) + .map(|dt| html_escape(&dt.format("%a, %b %-d, %Y at %-I:%M\u{202f}%p").to_string())) + .unwrap_or_else(|e| { + eprintln!( + "Note: could not parse date as RFC 2822 ({}); using raw value.", + sanitize_for_terminal(&e.to_string()) + ); + html_escape(raw_date) + }) +} + +/// Format the From line for a forwarded message using Gmail's `gmail_sendername` structure. +/// When the address has a display name, it is shown in `` with the email in a mailto +/// link. Bare emails appear in both positions (matching Gmail's behavior). +pub(super) fn format_forward_from(mailbox: &Mailbox) -> String { + let display = match &mailbox.name { + Some(name) => name.as_str(), + None => &mailbox.email, + }; + format!( + "{} \ + <{}>", + html_escape(display), + format_email_link(&mailbox.email), + ) +} + +/// Threading headers for reply/forward. +/// +/// IDs must be bare (no angle brackets) — `set_threading_headers` passes them to +/// mail-builder which adds angle brackets per RFC 5322. `in_reply_to` is a single +/// message ID (the direct parent); `references` is the full ordered chain. +/// The references chain should be fully assembled via `build_references_chain` +/// before constructing this. +pub(super) struct ThreadingHeaders<'a> { + pub in_reply_to: &'a str, + pub references: &'a [String], +} + +/// Build the full references chain for threading: existing references + current message ID. +pub(super) fn build_references_chain(original: &OriginalMessage) -> Vec { + let mut refs = original.references.clone(); + if !original.message_id.is_empty() { + refs.push(original.message_id.clone()); + } + refs +} + +/// Set threading headers on a `mail_builder::MessageBuilder`. +/// See `ThreadingHeaders` for the bare-ID convention. +pub(super) fn set_threading_headers<'x>( + mb: mail_builder::MessageBuilder<'x>, + threading: &ThreadingHeaders<'x>, +) -> mail_builder::MessageBuilder<'x> { + debug_assert!( + !threading.in_reply_to.contains('<'), + "threading IDs must be bare (no angle brackets)" + ); + debug_assert!( + threading.references.iter().all(|id| !id.contains('<')), + "threading IDs must be bare (no angle brackets)" + ); + + use mail_builder::headers::message_id::MessageId; + + let in_reply_to = MessageId::new(threading.in_reply_to); + let refs = MessageId { + id: threading + .references + .iter() + .map(|id| id.as_str().into()) + .collect(), + }; + + mb.in_reply_to(in_reply_to).references(refs) +} + +/// Apply optional From, CC, and BCC headers to a `MessageBuilder`. +pub(super) fn apply_optional_headers<'x>( + mut mb: mail_builder::MessageBuilder<'x>, + from: Option<&'x [Mailbox]>, + cc: Option<&'x [Mailbox]>, + bcc: Option<&'x [Mailbox]>, +) -> mail_builder::MessageBuilder<'x> { + if let Some(from) = from { + mb = mb.from(to_mb_address_list(from)); + } + if let Some(cc) = cc { + mb = mb.cc(to_mb_address_list(cc)); + } + if let Some(bcc) = bcc { + mb = mb.bcc(to_mb_address_list(bcc)); + } + mb +} + +/// Set the body (plain or HTML), add any attachments, and write the finished message to a string. +/// +/// When the message is HTML and contains inline parts (with `content_id`), builds a +/// `multipart/related` container so `cid:` references render correctly. Gmail's API +/// rewrites `Content-Disposition: inline` to `attachment` when parts sit in +/// `multipart/mixed`, so the explicit `multipart/related` structure is required. +pub(super) fn finalize_message( + mb: mail_builder::MessageBuilder<'_>, + body: impl Into, + html: bool, + attachments: &[Attachment], +) -> Result { + use mail_builder::mime::MimePart; + + let body_str = body.into(); + + let (inline, regular): (Vec<_>, Vec<_>) = attachments.iter().partition(|a| a.is_inline()); + + let mb = if html && !inline.is_empty() { + // Build multipart/related: HTML body + inline image parts + let mut related_parts: Vec> = + vec![MimePart::new("text/html", body_str.as_str())]; + for att in &inline { + let cid = att + .content_id + .as_deref() + .expect("partitioned by content_id presence"); + related_parts.push( + MimePart::new(att.content_type.as_str(), att.data.as_slice()) + .inline() + .cid(cid), + ); + } + let related = MimePart::new("multipart/related", related_parts); + + if regular.is_empty() { + // Just multipart/related — no outer mixed wrapper needed + mb.body(related) + } else { + // Wrap in multipart/mixed with regular attachments + let mut mixed_parts = vec![related]; + for att in ®ular { + mixed_parts.push( + MimePart::new(att.content_type.as_str(), att.data.as_slice()) + .attachment(att.filename.as_str()), + ); + } + mb.body(MimePart::new("multipart/mixed", mixed_parts)) + } + } else { + // No inline images, or plain-text mode — all parts become regular attachments. + // Callers strip inline parts in plain-text mode (matching Gmail web), so + // only regular attachments should reach here. If any inline parts do arrive, + // they are treated as regular attachments (defense-in-depth). + let mb = if html { + mb.html_body(body_str) + } else { + mb.text_body(body_str) + }; + attachments.iter().fold(mb, |mb, att| { + mb.attachment(&att.content_type, &att.filename, att.data.as_slice()) + }) + }; + + mb.write_to_string() + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to serialize email: {e}"))) +} + +/// Parse an optional clap argument, trimming whitespace and treating +/// empty/whitespace-only values as None. +pub(super) fn parse_optional_trimmed(matches: &ArgMatches, name: &str) -> Option { + matches + .get_one::(name) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Parse an optional clap argument as a comma-separated mailbox list. +/// Returns `None` when the argument is absent, empty, or yields no valid addresses. +pub(super) fn parse_optional_mailboxes(matches: &ArgMatches, name: &str) -> Option> { + parse_optional_trimmed(matches, name) + .map(|s| Mailbox::parse_list(&s)) + .filter(|v| !v.is_empty()) +} + +/// Gmail API upload endpoint limit is 35MB (per discovery document). Messages are +/// sent as multipart/related with the raw RFC 5322 message as the media part, so +/// the limit applies to the entire MIME message including headers, body, and +/// base64-encoded attachments. 25MB raw attachments ≈ 33MB with base64 + overhead. +const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024; + +/// A file attachment ready to add to an outgoing message. +/// +/// Created either from a local file (`--attach`, where `content_type` is +/// inferred from the extension via `mime_guess2`) or from an original +/// message's MIME part (`fetch_original_parts`, where `content_type` comes +/// from the Gmail API). mail-builder handles RFC 2231 encoding for non-ASCII +/// filenames in the Content-Disposition header. +#[derive(Debug)] +pub(super) struct Attachment { + pub filename: String, + pub content_type: String, + pub data: Vec, + /// When present, this part is an inline image. Used by `finalize_message` to + /// place the part inside a `multipart/related` container with `.inline().cid()`. + pub content_id: Option, +} + +impl Attachment { + /// Whether this attachment is an inline image (has a Content-ID) vs a regular file. + pub fn is_inline(&self) -> bool { + self.content_id.is_some() + } +} + +/// Read and validate attachments from `--attach` arguments. +/// +/// Rejects control characters in paths, non-regular files, empty files, +/// and total size exceeding `MAX_TOTAL_ATTACHMENT_BYTES`. +/// +/// Absolute and relative paths are both allowed. Unlike `--output-dir` (where +/// write confinement matters), `--attach` only reads files the user's process +/// already has access to. Path traversal restrictions would not prevent data +/// exfiltration — an agent could read any file via other means (e.g., shell +/// commands). The real mitigation for agent misuse is `--dry-run` and human +/// review of the command before execution. +pub(super) fn parse_attachments(matches: &ArgMatches) -> Result, GwsError> { + let paths: Vec<&string> = matches + .get_many::("attach") + .map(|v| v.collect()) + .unwrap_or_default(); + + let mut attachments = Vec::with_capacity(paths.len()); + let mut total_bytes: u64 = 0; + + for path in paths { + let canonical = crate::validate::validate_safe_file_path(path, "--attach")?; + + let metadata = std::fs::metadata(&canonical) + .map_err(|e| GwsError::Validation(format!("Cannot read --attach '{path}': {e}")))?; + if !metadata.is_file() { + return Err(GwsError::Validation(format!( + "--attach '{path}' is not a regular file" + ))); + } + + let data = std::fs::read(&canonical) + .map_err(|e| GwsError::Validation(format!("Cannot read --attach '{path}': {e}")))?; + if data.is_empty() { + return Err(GwsError::Validation(format!( + "--attach '{path}' is empty (0 bytes)" + ))); + } + // Size check uses actual bytes read, not metadata, to avoid TOCTOU race + total_bytes += data.len() as u64; + if total_bytes> MAX_TOTAL_ATTACHMENT_BYTES { + return Err(GwsError::Validation(format!( + "Total attachment size exceeds {}MB limit", + MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024) + ))); + } + // file_name() is None for paths like "/", "..", or "." — already caught by is_file(). + // to_str() is None only for non-UTF-8 filenames — impossible since path is &String. + let filename = canonical + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + GwsError::Validation(format!("--attach '{path}': could not extract filename")) + })?; + let content_type = mime_guess2::from_path(&canonical) + .first_or_octet_stream() + .to_string(); + + attachments.push(Attachment { + filename: filename.to_string(), + content_type, + data, + content_id: None, + }); + } + + Ok(attachments) +} + +fn resolve_send_method( + doc: &crate::discovery::RestDescription, +) -> Result<&crate::discovery::restmethod, GwsError> { + let users_res = doc + .resources + .get("users") + .ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?; + let messages_res = users_res + .resources + .get("messages") + .ok_or_else(|| GwsError::Discovery("Resource 'users.messages' not found".to_string()))?; + messages_res + .methods + .get("send") + .ok_or_else(|| GwsError::Discovery("Method 'users.messages.send' not found".to_string())) +} + +fn resolve_draft_method( + doc: &crate::discovery::RestDescription, +) -> Result<&crate::discovery::restmethod, GwsError> { + let users_res = doc + .resources + .get("users") + .ok_or_else(|| GwsError::Discovery("Resource 'users' not found".to_string()))?; + let drafts_res = users_res + .resources + .get("drafts") + .ok_or_else(|| GwsError::Discovery("Resource 'users.drafts' not found".to_string()))?; + drafts_res + .methods + .get("create") + .ok_or_else(|| GwsError::Discovery("Method 'users.drafts.create' not found".to_string())) +} + +/// Resolve either `users.drafts.create` or `users.messages.send` based on the draft flag. +pub(super) fn resolve_mail_method( + doc: &crate::discovery::RestDescription, + draft: bool, +) -> Result<&crate::discovery::restmethod, GwsError> { + if draft { + resolve_draft_method(doc) + } else { + resolve_send_method(doc) + } +} + +/// Build the JSON metadata for the upload endpoint. +/// +/// For `users.messages.send`: `{"threadId": "..."}` (only when replying/forwarding); +/// returns `None` for new messages. +/// For `users.drafts.create`: `{"message": {"threadId": "..."}}` when replying/forwarding, +/// or `{"message": {}}` for a new draft (wrapper is always required). +fn build_send_metadata(thread_id: Option<&str>, draft: bool) -> Option { + if draft { + let message = match thread_id { + Some(id) => json!({ "message": { "threadId": id } }), + None => json!({ "message": {} }), + }; + Some(message.to_string()) + } else { + thread_id.map(|id| json!({ "threadId": id }).to_string()) + } +} + +pub(super) async fn dispatch_raw_email( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, + raw_message: &str, + thread_id: Option<&str>, + existing_token: Option<&str>, +) -> Result<(), GwsError> { + let draft = matches.get_flag("draft"); + let metadata = build_send_metadata(thread_id, draft); + let method = resolve_mail_method(doc, draft)?; + let params = json!({ "userId": "me" }); + let params_str = params.to_string(); + + let (token, auth_method) = match existing_token { + Some(t) => (Some(t.to_string()), executor::AuthMethod::OAuth), + None => { + let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); + match auth::get_token(&scopes).await { + Ok(t) => (Some(t), executor::AuthMethod::OAuth), + Err(e) if matches.get_flag("dry-run") => { + eprintln!("Note: auth skipped for dry-run ({e})"); + (None, executor::AuthMethod::None) + } + Err(e) => return Err(GwsError::Auth(format!("Gmail auth failed: {e}"))), + } + } + }; + + let pagination = executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + }; + + executor::execute_method( + doc, + method, + Some(¶ms_str), + metadata.as_deref(), + token.as_deref(), + auth_method, + None, + Some(executor::UploadSource::Bytes { + data: raw_message.as_bytes(), + content_type: "message/rfc822", + }), + matches.get_flag("dry-run"), + &pagination, + None, + &crate::helpers::modelarmor::SanitizeMode::Warn, + &crate::formatter::OutputFormat::default(), + false, + ) + .await?; + + if draft && !matches.get_flag("dry-run") { + eprintln!("Tip: copy the draft \"id\" from the response above, then send with:"); + eprintln!(" gws gmail users.drafts.send --body '{{\"id\":\"\"}}'"); + } + + Ok(()) +} + +/// Add common arguments shared by all mail subcommands (--attach, --cc, --bcc, --html, --dry-run, --draft). +fn common_mail_args(cmd: Command) -> Command { + cmd.arg( + Arg::new("attach") + .short('a') + .long("attach") + .help("Attach a file (can be specified multiple times)") + .action(ArgAction::Append) + .value_name("PATH"), + ) + .arg( + Arg::new("cc") + .long("cc") + .help("CC email address(es), comma-separated") + .value_name("EMAILS"), + ) + .arg( + Arg::new("bcc") + .long("bcc") + .help("BCC email address(es), comma-separated") + .value_name("EMAILS"), + ) + .arg( + Arg::new("html") + .long("html") + .help("Treat --body as HTML content (default is plain text)") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .help("Show the request that would be sent without executing it") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("draft") + .long("draft") + .help("Save as draft instead of sending") + .action(ArgAction::SetTrue), + ) +} + +/// Add arguments shared by +reply and +reply-all (everything except --remove). +fn common_reply_args(cmd: Command) -> Command { + common_mail_args( + cmd.arg( + Arg::new("message-id") + .long("message-id") + .help("Gmail message ID to reply to") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("body") + .long("body") + .help("Reply body (plain text, or HTML with --html)") + .required(true) + .value_name("TEXT"), + ) + .arg( + Arg::new("from") + .long("from") + .help("Sender address (for send-as/alias; omit to use account default)") + .value_name("EMAIL"), + ) + .arg( + Arg::new("to") + .long("to") + .help("Additional To email address(es), comma-separated") + .value_name("EMAILS"), + ), + ) +} + +impl Helper for GmailHelper { + /// Register all Gmail helper subcommands (`+send`, `+reply`, `+reply-all`, + /// `+forward`, `+triage`, `+watch`) with their arguments and help text. + fn inject_commands( + &self, + mut cmd: Command, + _doc: &crate::discovery::RestDescription, + ) -> Command { + cmd = cmd.subcommand( + common_mail_args( + Command::new("+send") + .about("[Helper] Send an email") + .arg( + Arg::new("to") + .long("to") + .help("Recipient email address(es), comma-separated") + .required(true) + .value_name("EMAILS"), + ) + .arg( + Arg::new("subject") + .long("subject") + .help("Email subject") + .required(true) + .value_name("SUBJECT"), + ) + .arg( + Arg::new("body") + .long("body") + .help("Email body (plain text, or HTML with --html)") + .required(true) + .value_name("TEXT"), + ) + .arg( + Arg::new("from") + .long("from") + .help("Sender address (for send-as/alias; omit to use account default)") + .value_name("EMAIL"), + ), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi Alice!' + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --cc bob@example.com + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Bold text' --html + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --from alias@example.com + gws gmail +send --to alice@example.com --subject 'Report' --body 'See attached' -a report.pdf + gws gmail +send --to alice@example.com --subject 'Files' --body 'Two files' -a a.pdf -a b.csv + gws gmail +send --to alice@example.com --subject 'Hello' --body 'Hi!' --draft + +TIPS: + Handles RFC 5322 formatting, MIME encoding, and base64 automatically. + Use --from to send from a configured send-as alias instead of your primary address. + Use -a/--attach to add file attachments. Can be specified multiple times. Total size limit: 25MB. + With --html, use fragment tags (

, , ,
, etc.) — no / wrapper needed. + Use --draft to save the message as a draft instead of sending it immediately.", + ), + ); + + cmd = cmd.subcommand( + Command::new("+triage") + .about("[Helper] Show unread inbox summary (sender, subject, date)") + .arg( + Arg::new("max") + .long("max") + .help("Maximum messages to show (default: 20)") + .default_value("20") + .value_name("N"), + ) + .arg( + Arg::new("query") + .long("query") + .help("Gmail search query (default: is:unread)") + .value_name("QUERY"), + ) + .arg( + Arg::new("labels") + .long("labels") + .help("Include label names in output") + .action(ArgAction::SetTrue), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +triage + gws gmail +triage --max 5 --query 'from:boss' + gws gmail +triage --format json | jq '.[].subject' + gws gmail +triage --labels + +TIPS: + Read-only — never modifies your mailbox. + Defaults to table output format.", + ), + ); + + cmd = cmd.subcommand( + common_reply_args( + Command::new("+reply") + .about("[Helper] Reply to a message (handles threading automatically)"), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +reply --message-id 18f1a2b3c4d --body 'Thanks, got it!' + gws gmail +reply --message-id 18f1a2b3c4d --body 'Looping in Carol' --cc carol@example.com + gws gmail +reply --message-id 18f1a2b3c4d --body 'Adding Dave' --to dave@example.com + gws gmail +reply --message-id 18f1a2b3c4d --body 'Bold reply' --html + gws gmail +reply --message-id 18f1a2b3c4d --body 'Updated version' -a updated.docx + gws gmail +reply --message-id 18f1a2b3c4d --body 'Draft reply' --draft + +TIPS: + Automatically sets In-Reply-To, References, and threadId headers. + Quotes the original message in the reply body. + --to adds extra recipients to the To field. + Use -a/--attach to add file attachments. Can be specified multiple times. + With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ +Use fragment tags (

, , , etc.) — no / wrapper needed. + With --html, inline images in the quoted message are preserved via cid: references. + Use --draft to save the reply as a draft instead of sending it immediately. + For reply-all, use +reply-all instead.", + ), + ); + + cmd = cmd.subcommand( + common_reply_args( + Command::new("+reply-all") + .about("[Helper] Reply-all to a message (handles threading automatically)"), + ) + .arg( + Arg::new("remove") + .long("remove") + .help("Exclude recipients from the outgoing reply (comma-separated emails)") + .value_name("EMAILS"), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Sounds good to me!' + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Updated' --remove bob@example.com + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Adding Eve' --cc eve@example.com + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Noted' --html + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Notes attached' -a notes.pdf + gws gmail +reply-all --message-id 18f1a2b3c4d --body 'Draft reply' --draft + +TIPS: + Replies to the sender and all original To/CC recipients. + Use --to to add extra recipients to the To field. + Use --cc to add new CC recipients. + Use --bcc for recipients who should not be visible to others. + Use --remove to exclude recipients from the outgoing reply, including the sender or Reply-To target. + The command fails if no To recipient remains after exclusions and --to additions. + Use -a/--attach to add file attachments. Can be specified multiple times. + With --html, the quoted block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ +Use fragment tags (

, , , etc.) — no / wrapper needed. + With --html, inline images in the quoted message are preserved via cid: references. + Use --draft to save the reply as a draft instead of sending it immediately.", + ), + ); + + cmd = cmd.subcommand( + common_mail_args( + Command::new("+forward") + .about("[Helper] Forward a message to new recipients") + .arg( + Arg::new("message-id") + .long("message-id") + .help("Gmail message ID to forward") + .required(true) + .value_name("ID"), + ) + .arg( + Arg::new("to") + .long("to") + .help("Recipient email address(es), comma-separated") + .required(true) + .value_name("EMAILS"), + ) + .arg( + Arg::new("from") + .long("from") + .help("Sender address (for send-as/alias; omit to use account default)") + .value_name("EMAIL"), + ) + .arg( + Arg::new("body") + .long("body") + .help("Optional note to include above the forwarded message (plain text, or HTML with --html)") + .value_name("TEXT"), + ) + .arg( + Arg::new("no-original-attachments") + .long("no-original-attachments") + .help("Do not include file attachments from the original message (inline images in --html mode are preserved)") + .action(ArgAction::SetTrue), + ), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body 'FYI see below' + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --cc eve@example.com + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --body '

FYI

' --html + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com -a notes.pdf + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --no-original-attachments + gws gmail +forward --message-id 18f1a2b3c4d --to dave@example.com --draft + +TIPS: + Includes the original message with sender, date, subject, and recipients. + Original attachments are included by default (matching Gmail web behavior). + With --html, inline images are also preserved via cid: references. + In plain-text mode, inline images are not included (matching Gmail web). + Use --no-original-attachments to forward without the original message's files. + Use -a/--attach to add extra file attachments. Can be specified multiple times. + Combined size of original and user attachments is limited to 25MB. + With --html, the forwarded block uses Gmail's gmail_quote CSS classes and preserves HTML formatting. \ +Use fragment tags (

, , , etc.) — no / wrapper needed. + Use --draft to save the forward as a draft instead of sending it immediately.", + ), + ); + + cmd = cmd.subcommand( + Command::new("+read") + .about("[Helper] Read a message and extract its body or headers") + .arg( + Arg::new("id") + .long("id") + .alias("message-id") + .required(true) + .help("The Gmail message ID to read") + .value_name("ID"), + ) + .arg( + Arg::new("headers") + .long("headers") + .help("Include headers (From, To, Subject, Date) in the output") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("format") + .long("format") + .help("Output format (text, json)") + .value_parser(["text", "json"]) + .default_value("text"), + ) + .arg( + Arg::new("html") + .long("html") + .help("Return HTML body instead of plain text") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .help("Show the request that would be sent without executing it") + .action(ArgAction::SetTrue), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +read --id 18f1a2b3c4d + gws gmail +read --id 18f1a2b3c4d --headers + gws gmail +read --id 18f1a2b3c4d --format json | jq '.body' + +TIPS: + Converts HTML-only messages to plain text automatically. + Handles multipart/alternative and base64 decoding.", + ), + ); + + cmd = cmd.subcommand( + Command::new("+watch") + .about("[Helper] Watch for new emails and stream them as NDJSON") + .arg( + Arg::new("project") + .long("project") + .help("GCP project ID for Pub/Sub resources") + .value_name("PROJECT"), + ) + .arg( + Arg::new("subscription") + .long("subscription") + .help("Existing Pub/Sub subscription name (skip setup)") + .value_name("NAME"), + ) + .arg( + Arg::new("topic") + .long("topic") + .help("Existing Pub/Sub topic with Gmail push permission already granted") + .value_name("TOPIC"), + ) + .arg( + Arg::new("label-ids") + .long("label-ids") + .help("Comma-separated Gmail label IDs to filter (e.g., INBOX,UNREAD)") + .value_name("LABELS"), + ) + .arg( + Arg::new("max-messages") + .long("max-messages") + .help("Max messages per pull batch") + .value_name("N") + .default_value("10"), + ) + .arg( + Arg::new("poll-interval") + .long("poll-interval") + .help("Seconds between pulls") + .value_name("SECS") + .default_value("5"), + ) + .arg( + Arg::new("msg-format") + .long("msg-format") + .help("Gmail message format: full, metadata, minimal, raw") + .value_name("FORMAT") + .value_parser(["full", "metadata", "minimal", "raw"]) + .default_value("full"), + ) + .arg( + Arg::new("once") + .long("once") + .help("Pull once and exit") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("cleanup") + .long("cleanup") + .help("Delete created Pub/Sub resources on exit") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("output-dir") + .long("output-dir") + .help("Write each message to a separate JSON file in this directory") + .value_name("DIR"), + ) + .after_help( + "\ +EXAMPLES: + gws gmail +watch --project my-gcp-project + gws gmail +watch --project my-project --label-ids INBOX --once + gws gmail +watch --subscription projects/p/subscriptions/my-sub + gws gmail +watch --project my-project --cleanup --output-dir ./emails + +TIPS: + Gmail watch expires after 7 days — re-run to renew. + Without --cleanup, Pub/Sub resources persist for reconnection. + Press Ctrl-C to stop gracefully.", + ), + ); + + cmd + } + + fn handle<'a>( + &'a self, + doc: &'a crate::discovery::RestDescription, + matches: &'a ArgMatches, + sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + if let Some(matches) = matches.subcommand_matches("+send") { + handle_send(doc, matches).await?; + return Ok(true); + } + + if let Some(matches) = matches.subcommand_matches("+reply") { + handle_reply(doc, matches, false).await?; + return Ok(true); + } + + if let Some(matches) = matches.subcommand_matches("+reply-all") { + handle_reply(doc, matches, true).await?; + return Ok(true); + } + + if let Some(matches) = matches.subcommand_matches("+forward") { + handle_forward(doc, matches).await?; + return Ok(true); + } + + if let Some(matches) = matches.subcommand_matches("+triage") { + handle_triage(matches).await?; + return Ok(true); + } + + if let Some(matches) = matches.subcommand_matches("+read") { + handle_read(doc, matches).await?; + return Ok(true); + } + + if let Some(matches) = matches.subcommand_matches("+watch") { + handle_watch(matches, sanitize_config).await?; + return Ok(true); + } + + Ok(false) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + /// Test-only wrapper: extract the plain text body from a payload using the single-pass walker. + fn extract_plain_text_body(payload: &Value) -> Option { + extract_payload_contents(payload).body_text + } + + /// Test-only wrapper: extract the HTML body from a payload using the single-pass walker. + fn extract_html_body(payload: &Value) -> Option { + extract_payload_contents(payload).body_html + } + + // --- Shared test helpers --- + + /// Extract a header value from raw RFC 5322 output, handling folded lines. + /// Only searches the header block (before the first blank line). + pub(super) fn extract_header(raw: &str, name: &str) -> Option { + let prefix = format!("{}:", name); + let mut result: Option = None; + let mut collecting = false; + for line in raw.lines() { + // Blank line = end of headers per RFC 5322 + if line.is_empty() || line == "\r" { + break; + } + if line.len()>= prefix.len() && line[..prefix.len()].eq_ignore_ascii_case(&prefix) { + result = Some(line[prefix.len()..].trim().to_string()); + collecting = true; + } else if collecting && (line.starts_with(' ') || line.starts_with('\t')) { + if let Some(ref mut r) = result { + r.push(' '); + r.push_str(line.trim()); + } + } else { + collecting = false; + } + } + result + } + + /// Strip quoted-printable soft line breaks from raw output. + pub(super) fn strip_qp_soft_breaks(raw: &str) -> String { + raw.replace("=\r\n", "").replace("=\n", "") + } + + // --- mail-builder integration tests --- + + #[test] + fn test_to_mb_address_bare_email() { + let mailbox = Mailbox::parse("alice@example.com"); + let mut mb = mail_builder::MessageBuilder::new(); + mb = mb + .to(to_mb_address(&mailbox)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + let to = extract_header(&raw, "To").unwrap(); + assert!(to.contains("alice@example.com")); + } + + #[test] + fn test_to_mb_address_with_display_name() { + let mailbox = Mailbox::parse("Alice Smith "); + let mut mb = mail_builder::MessageBuilder::new(); + mb = mb + .to(to_mb_address(&mailbox)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + let to = extract_header(&raw, "To").unwrap(); + assert!(to.contains("alice@example.com")); + assert!(to.contains("Alice Smith")); + } + + #[test] + fn test_to_mb_address_list_multiple() { + let mailboxes = Mailbox::parse_list("alice@example.com, Bob "); + let mut mb = mail_builder::MessageBuilder::new(); + mb = mb + .to(to_mb_address_list(&mailboxes)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + let to = extract_header(&raw, "To").unwrap(); + assert!(to.contains("alice@example.com")); + assert!(to.contains("bob@example.com")); + assert!(to.contains("Bob")); + } + + #[test] + fn test_set_threading_headers_output() { + let refs = vec![ + "ref-1@example.com".to_string(), + "ref-2@example.com".to_string(), + ]; + let threading = ThreadingHeaders { + in_reply_to: "reply-to@example.com", + references: &refs, + }; + let mb = mail_builder::MessageBuilder::new(); + let mb = mb + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test") + .text_body("body"); + let mb = set_threading_headers(mb, &threading); + let raw = mb.write_to_string().unwrap(); + + let in_reply_to = extract_header(&raw, "In-Reply-To").unwrap(); + assert!(in_reply_to.contains("reply-to@example.com")); + + let references = extract_header(&raw, "References").unwrap(); + assert!(references.contains("ref-1@example.com")); + assert!(references.contains("ref-2@example.com")); + } + + // --- OriginalMessage tests --- + + #[test] + fn test_original_message_default() { + let d = OriginalMessage::default(); + assert!(d.thread_id.is_none()); + assert!(d.message_id.is_empty()); + assert!(d.references.is_empty()); + assert!(d.from.email.is_empty()); + assert!(d.from.name.is_none()); + assert!(d.reply_to.is_none()); + assert!(d.to.is_empty()); + assert!(d.cc.is_none()); + assert!(d.subject.is_empty()); + assert!(d.date.is_none()); + assert!(d.body_text.is_empty()); + assert!(d.body_html.is_none()); + assert!(d.parts.is_empty()); + } + + #[test] + fn test_parse_original_message_minimal() { + let msg = json!({ + "threadId": "t1", + "snippet": "fallback text", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Subject", "value": "Hi" }, + { "name": "Message-ID", "value": "" } + ], + "body": { + "data": URL_SAFE.encode("Hello") + } + } + }); + let original = parse_original_message(&msg).unwrap(); + assert_eq!(original.thread_id.as_deref(), Some("t1")); + assert_eq!(original.from.email, "alice@example.com"); + assert_eq!(original.subject, "Hi"); + assert_eq!(original.body_text, "Hello"); + assert_eq!(original.message_id, "min@example.com"); + // Missing optional fields default to None/empty + assert!(original.reply_to.is_none()); + assert!(original.cc.is_none()); + assert!(original.date.is_none()); + assert!(original.references.is_empty()); + assert!(original.body_html.is_none()); + } + + #[test] + fn test_parse_original_message_bare_message_id() { + let msg = json!({ + "threadId": "t1", + "snippet": "", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Subject", "value": "Hi" }, + { "name": "Message-ID", "value": "bare-id@example.com" } + ], + "body": { "data": URL_SAFE.encode("text") } + } + }); + let original = parse_original_message(&msg).unwrap(); + // Bare ID (no angle brackets) should be preserved as-is + assert_eq!(original.message_id, "bare-id@example.com"); + } + + #[test] + fn test_parse_original_message_missing_payload() { + let msg = json!({ + "threadId": "t1", + "snippet": "fallback" + }); + // Missing payload means no From or Message-ID → error + let result = parse_original_message(&msg); + assert!(result.is_err()); + } + + #[test] + fn test_parse_original_message_missing_thread_id() { + let msg = json!({ + "snippet": "text", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Message-ID", "value": "" } + ], + "body": { "data": URL_SAFE.encode("Hello") } + } + }); + let result = parse_original_message(&msg).unwrap(); + assert!(result.thread_id.is_none()); + } + + #[test] + fn test_parse_original_message_missing_from() { + let msg = json!({ + "threadId": "t1", + "snippet": "text", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "Message-ID", "value": "" } + ], + "body": { "data": URL_SAFE.encode("Hello") } + } + }); + let result = parse_original_message(&msg); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("From")); + } + + #[test] + fn test_parse_original_message_missing_message_id() { + let msg = json!({ + "threadId": "t1", + "snippet": "text", + "payload": { + "mimeType": "text/plain", + "headers": [ + { "name": "From", "value": "alice@example.com" } + ], + "body": { "data": URL_SAFE.encode("Hello") } + } + }); + let result = parse_original_message(&msg); + assert!(result.is_err()); + assert!(result.err().unwrap().to_string().contains("Message-ID")); + } + + #[test] + fn test_parse_original_message_snippet_fallback() { + // When only text/html is present (no text/plain), body_text falls back to snippet + let msg = json!({ + "threadId": "t1", + "snippet": "Snippet fallback text", + "payload": { + "mimeType": "text/html", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Message-ID", "value": "" } + ], + "body": { "data": URL_SAFE.encode("

HTML only

") } + } + }); + let original = parse_original_message(&msg).unwrap(); + assert_eq!(original.body_text, "Snippet fallback text"); + assert_eq!(original.body_html.unwrap(), "

HTML only

"); + } + + // --- extract_plain_text_body tests --- + + #[test] + fn test_extract_plain_text_body_simple() { + let payload = json!({ + "mimeType": "text/plain", + "body": { + "data": URL_SAFE.encode("Hello, world!") + } + }); + assert_eq!(extract_plain_text_body(&payload).unwrap(), "Hello, world!"); + } + + #[test] + fn test_extract_plain_text_body_multipart() { + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("Plain text body") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

HTML body

") } + } + ] + }); + assert_eq!( + extract_plain_text_body(&payload).unwrap(), + "Plain text body" + ); + } + + #[test] + fn test_extract_plain_text_body_nested_multipart() { + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("Nested plain text") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

HTML

") } + } + ] + }, + { + "mimeType": "application/pdf", + "body": { "attachmentId": "att123" } + } + ] + }); + assert_eq!( + extract_plain_text_body(&payload).unwrap(), + "Nested plain text" + ); + } + + #[test] + fn test_extract_plain_text_body_no_text_part() { + let payload = json!({ + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

Only HTML

") } + }); + assert!(extract_plain_text_body(&payload).is_none()); + } + + #[test] + fn test_inject_commands() { + let helper = GmailHelper; + let cmd = Command::new("test"); + let doc = crate::discovery::RestDescription::default(); + + let cmd = helper.inject_commands(cmd, &doc); + let subcommands: Vec<_> = cmd.get_subcommands().map(|s| s.get_name()).collect(); + assert!(subcommands.contains(&"+watch")); + assert!(subcommands.contains(&"+send")); + assert!(subcommands.contains(&"+reply")); + assert!(subcommands.contains(&"+reply-all")); + assert!(subcommands.contains(&"+forward")); + assert!(subcommands.contains(&"+read")); + } + + #[test] + fn test_build_send_metadata_with_thread_id() { + let metadata = build_send_metadata(Some("thread-123"), false).unwrap(); + let parsed: Value = serde_json::from_str(&metadata).unwrap(); + assert_eq!(parsed["threadId"], "thread-123"); + } + + #[test] + fn test_build_send_metadata_without_thread_id() { + assert!(build_send_metadata(None, false).is_none()); + } + + #[test] + fn test_build_send_metadata_draft_with_thread_id() { + let metadata = build_send_metadata(Some("thread-123"), true).unwrap(); + let parsed: Value = serde_json::from_str(&metadata).unwrap(); + assert_eq!(parsed["message"]["threadId"], "thread-123"); + } + + #[test] + fn test_build_send_metadata_draft_without_thread_id() { + let metadata = build_send_metadata(None, true).unwrap(); + let parsed: Value = serde_json::from_str(&metadata).unwrap(); + assert!(parsed["message"].is_object()); + assert!(parsed["message"].get("threadId").is_none()); + } + + #[test] + fn test_append_address_list_header_value() { + let mut header_value = String::new(); + + append_address_list_header_value(&mut header_value, "alice@example.com"); + append_address_list_header_value(&mut header_value, "bob@example.com"); + append_address_list_header_value(&mut header_value, ""); + + assert_eq!(header_value, "alice@example.com, bob@example.com"); + } + + #[test] + fn test_parse_original_message_concatenates_repeated_address_and_reference_headers() { + let msg = json!({ + "threadId": "thread-123", + "snippet": "Snippet fallback", + "payload": { + "mimeType": "text/html", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "Reply-To", "value": "team@example.com" }, + { "name": "Reply-To", "value": "owner@example.com" }, + { "name": "To", "value": "bob@example.com" }, + { "name": "To", "value": "carol@example.com" }, + { "name": "Cc", "value": "dave@example.com" }, + { "name": "Cc", "value": "erin@example.com" }, + { "name": "Subject", "value": "Hello" }, + { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" }, + { "name": "Message-ID", "value": "" }, + { "name": "References", "value": "" }, + { "name": "References", "value": "" } + ], + "body": { + "data": URL_SAFE.encode("

HTML only

") + } + } + }); + + let original = parse_original_message(&msg).unwrap(); + + assert_eq!(original.thread_id.as_deref(), Some("thread-123")); + assert_eq!(original.from.email, "alice@example.com"); + let reply_to = original.reply_to.unwrap(); + assert_eq!(reply_to.len(), 2); + assert_eq!(reply_to[0].email, "team@example.com"); + assert_eq!(reply_to[1].email, "owner@example.com"); + assert_eq!(original.to.len(), 2); + assert_eq!(original.to[0].email, "bob@example.com"); + assert_eq!(original.to[1].email, "carol@example.com"); + let cc = original.cc.unwrap(); + assert_eq!(cc.len(), 2); + assert_eq!(cc[0].email, "dave@example.com"); + assert_eq!(cc[1].email, "erin@example.com"); + assert_eq!(original.subject, "Hello"); + assert_eq!( + original.date.as_deref(), + Some("Fri, 6 Mar 2026 12:00:00 +0000") + ); + assert_eq!(original.message_id, "msg@example.com"); + assert_eq!( + original.references, + vec!["ref-1@example.com", "ref-2@example.com"] + ); + assert_eq!(original.body_text, "Snippet fallback"); + assert_eq!(original.body_html.as_deref(), Some("

HTML only

")); + } + + #[test] + fn test_parse_original_message_multipart_alternative() { + let msg = json!({ + "threadId": "thread-456", + "snippet": "Snippet ignored when text/plain exists", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "To", "value": "bob@example.com" }, + { "name": "Subject", "value": "Hello" }, + { "name": "Date", "value": "Fri, 6 Mar 2026 12:00:00 +0000" }, + { "name": "Message-ID", "value": "" } + ], + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("Plain text body") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

Rich HTML body

") } + } + ] + } + }); + + let original = parse_original_message(&msg).unwrap(); + + assert_eq!(original.body_text, "Plain text body"); + assert_eq!(original.body_html.as_deref(), Some("

Rich HTML body

")); + } + + #[test] + fn test_resolve_send_method_finds_gmail_send_method() { + let mut doc = crate::discovery::RestDescription::default(); + let send_method = crate::discovery::RestMethod { + http_method: "POST".to_string(), + path: "gmail/v1/users/{userId}/messages/send".to_string(), + ..Default::default() + }; + + let mut messages = crate::discovery::RestResource::default(); + messages.methods.insert("send".to_string(), send_method); + + let mut users = crate::discovery::RestResource::default(); + users.resources.insert("messages".to_string(), messages); + + doc.resources = HashMap::from([("users".to_string(), users)]); + + let resolved = resolve_send_method(&doc).unwrap(); + + assert_eq!(resolved.http_method, "POST"); + assert_eq!(resolved.path, "gmail/v1/users/{userId}/messages/send"); + } + + #[test] + fn test_resolve_draft_method_finds_gmail_drafts_create_method() { + let mut doc = crate::discovery::RestDescription::default(); + let create_method = crate::discovery::RestMethod { + http_method: "POST".to_string(), + path: "gmail/v1/users/{userId}/drafts".to_string(), + ..Default::default() + }; + + let mut drafts = crate::discovery::RestResource::default(); + drafts.methods.insert("create".to_string(), create_method); + + let mut users = crate::discovery::RestResource::default(); + users.resources.insert("drafts".to_string(), drafts); + + doc.resources = HashMap::from([("users".to_string(), users)]); + + let resolved = resolve_draft_method(&doc).unwrap(); + + assert_eq!(resolved.http_method, "POST"); + assert_eq!(resolved.path, "gmail/v1/users/{userId}/drafts"); + } + + #[test] + fn test_html_escape() { + assert_eq!(html_escape("Hello World"), "Hello World"); + assert_eq!( + html_escape("Tom & Jerry "), + "Tom & Jerry <tj@example.com>" + ); + assert_eq!( + html_escape("He said \"hello\""), + "He said "hello"" + ); + assert_eq!(html_escape("it's"), "it's"); + assert_eq!(html_escape(""), ""); + assert_eq!( + html_escape("a & b < c> d \"e\" f'g"), + "a & b < c > d "e" f'g" + ); + } + + #[test] + fn test_extract_html_body_direct() { + let payload = json!({ + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode("

Hello

") + } + }); + assert_eq!(extract_html_body(&payload).as_deref(), Some("

Hello

")); + } + + #[test] + fn test_extract_html_body_from_multipart() { + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("plain text") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

rich text

") } + } + ] + }); + assert_eq!( + extract_html_body(&payload).as_deref(), + Some("

rich text

") + ); + } + + #[test] + fn test_extract_html_body_missing() { + let payload = json!({ + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("only plain") } + }); + assert!(extract_html_body(&payload).is_none()); + } + + #[test] + fn test_extract_html_body_from_nested_multipart() { + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("plain text") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

Nested HTML

") } + } + ] + }, + { + "mimeType": "application/pdf", + "body": { "attachmentId": "att123" } + } + ] + }); + assert_eq!( + extract_html_body(&payload).as_deref(), + Some("

Nested HTML

") + ); + } + + #[test] + fn test_resolve_html_body_uses_html_when_present() { + let original = OriginalMessage { + body_text: "ignored".to_string(), + body_html: Some("

Real HTML

".to_string()), + ..OriginalMessage::dry_run_placeholder("test") + }; + assert_eq!(resolve_html_body(&original), "

Real HTML

"); + } + + #[test] + fn test_resolve_html_body_escapes_plain_text_fallback() { + let original = OriginalMessage { + body_text: "Line 1 & \nLine 2\r\nLine 3".to_string(), + body_html: None, + ..OriginalMessage::dry_run_placeholder("test") + }; + let result = resolve_html_body(&original); + assert_eq!( + result, + "Line 1 & <tag>
\r\nLine 2
\r\nLine 3" + ); + } + + // --- Mailbox type tests --- + + #[test] + fn test_mailbox_parse_bare_email() { + let m = Mailbox::parse("alice@example.com"); + assert_eq!(m.email, "alice@example.com"); + assert!(m.name.is_none()); + } + + #[test] + fn test_mailbox_parse_with_display_name() { + let m = Mailbox::parse("Alice Smith "); + assert_eq!(m.email, "alice@example.com"); + assert_eq!(m.name.as_deref(), Some("Alice Smith")); + } + + #[test] + fn test_mailbox_parse_quoted_display_name() { + let m = Mailbox::parse("\"Bob, Jr.\" "); + assert_eq!(m.email, "bob@example.com"); + assert_eq!(m.name.as_deref(), Some("Bob, Jr.")); + } + + #[test] + fn test_mailbox_parse_malformed_no_closing_bracket() { + let m = Mailbox::parse("Alice "); + // Empty email inside angle brackets + assert_eq!(m.email, ""); + assert_eq!(m.name.as_deref(), Some("Alice")); + } + + #[test] + fn test_mailbox_parse_strips_crlf_injection_in_email() { + let m = Mailbox::parse("foo@bar.com\r\nBcc: evil@attacker.com"); + assert_eq!(m.email, "foo@bar.comBcc: evil@attacker.com"); + assert!(!m.email.contains('\r')); + assert!(!m.email.contains('\n')); + } + + #[test] + fn test_mailbox_parse_strips_crlf_injection_in_angle_bracket_email() { + let m = Mailbox::parse("Alice "); + assert!(!m.email.contains('\r')); + assert!(!m.email.contains('\n')); + assert!(m.email.contains("foo@bar.com")); + } + + #[test] + fn test_mailbox_parse_strips_control_chars_from_name() { + let m = Mailbox::parse("Alice0円Bob "); + assert_eq!(m.name.as_deref(), Some("AliceBob")); + assert!(!m.name.unwrap().contains('0円')); + } + + #[test] + fn test_mailbox_parse_strips_null_bytes_from_email() { + let m = Mailbox::parse("alice0円@example.com"); + assert_eq!(m.email, "alice@example.com"); + } + + #[test] + fn test_mailbox_parse_strips_tab_from_email() { + let m = Mailbox::parse("alice\t@example.com"); + assert_eq!(m.email, "alice@example.com"); + } + + #[test] + fn test_mailbox_parse_non_ascii_display_name() { + let m = Mailbox::parse("田中太郎 "); + assert_eq!(m.email, "tanaka@example.com"); + assert_eq!(m.name.as_deref(), Some("田中太郎")); + + // Verify non-ASCII name flows through to mail-builder without panic + // and gets RFC 2047 encoded (replacing hand-rolled encode_address_header from #482) + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address(&m)) + .subject("test") + .text_body("body"); + let raw = mb.write_to_string().unwrap(); + assert!(raw.contains("tanaka@example.com")); + assert!(!raw.contains("田中太郎")); // raw CJK should be RFC 2047 encoded + assert!(raw.contains("=?utf-8?")); // encoded-word present + } + + #[test] + fn test_mailbox_parse_list() { + let list = Mailbox::parse_list("alice@example.com, Bob "); + assert_eq!(list.len(), 2); + assert_eq!(list[0].email, "alice@example.com"); + assert_eq!(list[1].email, "bob@example.com"); + assert_eq!(list[1].name.as_deref(), Some("Bob")); + } + + #[test] + fn test_mailbox_parse_list_with_quoted_comma() { + let list = Mailbox::parse_list(r#""Doe, John" , alice@example.com"#); + assert_eq!(list.len(), 2); + assert_eq!(list[0].email, "john@example.com"); + assert_eq!(list[0].name.as_deref(), Some("Doe, John")); + assert_eq!(list[1].email, "alice@example.com"); + } + + #[test] + fn test_mailbox_parse_list_filters_empty_emails() { + // Empty string → empty vec + assert!(Mailbox::parse_list("").is_empty()); + + // Whitespace-only commas → empty vec + assert!(Mailbox::parse_list(" , , ").is_empty()); + + // Trailing comma → no phantom entry + let list = Mailbox::parse_list("alice@example.com,"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].email, "alice@example.com"); + + // Leading comma + let list = Mailbox::parse_list(",alice@example.com"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].email, "alice@example.com"); + + // Empty angle brackets filtered + let list = Mailbox::parse_list("Alice , bob@example.com"); + assert_eq!(list.len(), 1); + assert_eq!(list[0].email, "bob@example.com"); + } + + #[test] + fn test_mailbox_display() { + let bare = Mailbox { + name: None, + email: "alice@example.com".to_string(), + }; + assert_eq!(bare.to_string(), "alice@example.com"); + + let named = Mailbox { + name: Some("Alice".to_string()), + email: "alice@example.com".to_string(), + }; + assert_eq!(named.to_string(), "Alice "); + } + + /// Regression test for PR #513: display names with RFC 2822 special characters + /// (commas, parens, colons, etc.) must be properly quoted in the To: header + /// so Gmail does not reject them with "Invalid To header". + #[test] + fn test_rfc2822_display_name_quoting_via_mail_builder() { + let test_cases = [ + ("Anderson, Rich (CORP)", "rich@example.com", "comma/parens"), + ("Dr. Smith: Chief", "smith@example.com", "colon"), + ("O'Brien & Co.", "ob@example.com", "dot/ampersand"), + ]; + + for (name, email, description) in test_cases { + let m = Mailbox { + name: Some(name.to_string()), + email: email.to_string(), + }; + let raw = mail_builder::MessageBuilder::new() + .to(to_mb_address(&m)) + .subject("test") + .text_body("body") + .write_to_string() + .unwrap(); + let to_line = raw + .lines() + .find(|l| l.starts_with("To:")) + .unwrap_or_else(|| panic!("No To: header for case: {description}")); + + let quoted = format!("\"{name}\""); + assert!( + to_line.contains("ed) || to_line.contains("=?utf-8?"), + "Display name with {description} must be quoted: {to_line}" + ); + } + } + + #[test] + fn test_strip_angle_brackets() { + assert_eq!(strip_angle_brackets(""), "abc@example.com"); + assert_eq!(strip_angle_brackets("abc@example.com"), "abc@example.com"); + assert_eq!( + strip_angle_brackets(" "), + "abc@example.com" + ); + } + + #[test] + fn test_build_references_chain() { + // Empty references + message ID + let original = OriginalMessage { + message_id: "msg-1@example.com".to_string(), + ..Default::default() + }; + assert_eq!(build_references_chain(&original), vec!["msg-1@example.com"]); + + // Existing references + message ID + let original = OriginalMessage { + message_id: "msg-2@example.com".to_string(), + references: vec![ + "msg-0@example.com".to_string(), + "msg-1@example.com".to_string(), + ], + ..Default::default() + }; + assert_eq!( + build_references_chain(&original), + vec![ + "msg-0@example.com", + "msg-1@example.com", + "msg-2@example.com" + ] + ); + + // Empty message ID doesn't add to chain + let original = OriginalMessage { + message_id: String::new(), + references: vec!["msg-0@example.com".to_string()], + ..Default::default() + }; + assert_eq!(build_references_chain(&original), vec!["msg-0@example.com"]); + } + + // --- HTML fidelity helper tests --- + + #[test] + fn test_format_sender_for_attribution() { + // Bare email + let bare = Mailbox::parse("alice@example.com"); + assert_eq!( + format_sender_for_attribution(&bare), + "
alice@example.com" + ); + // Name + let named = Mailbox::parse("Alice Smith "); + assert_eq!( + format_sender_for_attribution(&named), + "Alice Smith <alice@example.com>" + ); + // Special chars in name + let special = Mailbox::parse("O'Brien & Co "); + assert_eq!( + format_sender_for_attribution(&special), + "O'Brien & Co <ob@example.com>" + ); + } + + #[test] + fn test_format_email_link_prevents_mailto_injection() { + // A crafted email with ?cc= must be percent-encoded in the href so the + // browser does not interpret it as a mailto parameter. + let link = format_email_link("user@example.com?cc=evil@attacker.com"); + assert!(link.contains("mailto:")); + // The href must not contain raw ?cc= (it should be percent-encoded) + assert!(!link.contains("mailto:user@example.com?cc=")); + assert!(link.contains("%3F")); // ? encoded + assert!(link.contains("%3D")); // = encoded + } + + #[test] + fn test_format_address_list_with_links() { + let single = vec![Mailbox::parse("alice@example.com")]; + assert_eq!( + format_address_list_with_links(&single), + "alice@example.com" + ); + let multi = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + assert_eq!( + format_address_list_with_links(&multi), + "alice@example.com, \ + bob@example.com" + ); + let with_name = Mailbox::parse_list(r#""Doe, John" , alice@example.com"#); + assert_eq!( + format_address_list_with_links(&with_name), + "Doe, John <john@example.com>, \ + alice@example.com" + ); + assert_eq!(format_address_list_with_links(&[]), ""); + } + + #[test] + fn test_format_date_for_attribution() { + assert_eq!( + format_date_for_attribution("Wed, 04 Mar 2026 15:01:00 +0000"), + "Wed, Mar 4, 2026 at 3:01\u{202f}PM" + ); + assert_eq!( + format_date_for_attribution("Jan 1 <2026>"), + "Jan 1 <2026>" + ); + } + + #[test] + fn test_format_forward_from() { + let named = Mailbox::parse("Alice Smith "); + assert_eq!( + format_forward_from(&named), + "Alice Smith \ + <alice@example.com>" + ); + let bare = Mailbox::parse("alice@example.com"); + assert_eq!( + format_forward_from(&bare), + "alice@example.com \ + <alice@example.com>" + ); + } + + #[test] + fn test_split_raw_mailbox_list() { + assert_eq!( + split_raw_mailbox_list("alice@example.com, bob@example.com"), + vec!["alice@example.com", "bob@example.com"] + ); + assert_eq!( + split_raw_mailbox_list("alice@example.com"), + vec!["alice@example.com"] + ); + assert!(split_raw_mailbox_list("").is_empty()); + assert_eq!( + split_raw_mailbox_list(r#""Doe, John" , alice@example.com"#), + vec![r#""Doe, John" "#, "alice@example.com"] + ); + assert_eq!( + split_raw_mailbox_list(r#""Doe \"JD, Sr\"" , alice@example.com"#), + vec![ + r#""Doe \"JD, Sr\"" "#, + "alice@example.com" + ] + ); + assert_eq!( + split_raw_mailbox_list(r#""Trail\\" , b@example.com"#), + vec![r#""Trail\\" "#, "b@example.com"] + ); + } + + #[test] + fn test_parse_optional_trimmed() { + let cmd = Command::new("test") + .arg(Arg::new("flag").long("flag")) + .arg(Arg::new("empty").long("empty")) + .arg(Arg::new("ws").long("ws")); + + // Present, non-empty value + let matches = cmd + .clone() + .try_get_matches_from(["test", "--flag", "value"]) + .unwrap(); + assert_eq!( + parse_optional_trimmed(&matches, "flag"), + Some("value".to_string()) + ); + + // Absent argument + let matches = cmd.clone().try_get_matches_from(["test"]).unwrap(); + assert!(parse_optional_trimmed(&matches, "flag").is_none()); + + // Whitespace-only becomes None + let matches = cmd + .clone() + .try_get_matches_from(["test", "--ws", " "]) + .unwrap(); + assert!(parse_optional_trimmed(&matches, "ws").is_none()); + + // Empty string becomes None + let matches = cmd.try_get_matches_from(["test", "--empty", ""]).unwrap(); + assert!(parse_optional_trimmed(&matches, "empty").is_none()); + } + + // --- Attachment tests --- + + fn make_attach_matches(args: &[&str]) -> ArgMatches { + let cmd = Command::new("test").arg( + Arg::new("attach") + .short('a') + .long("attach") + .action(ArgAction::Append), + ); + cmd.try_get_matches_from(args).unwrap() + } + + #[test] + fn test_attachment_single_file() { + let att = Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf data".to_vec(), + content_id: None, + }; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "Body", false, &[att]).unwrap(); + + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("report.pdf")); + assert!(raw.contains("application/pdf")); + assert!(raw.contains("Body")); + } + + #[test] + fn test_attachment_multiple_files() { + let attachments = vec![ + Attachment { + filename: "a.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"pdf data".to_vec(), + content_id: None, + }, + Attachment { + filename: "b.csv".to_string(), + content_type: "text/csv".to_string(), + data: b"csv data".to_vec(), + content_id: None, + }, + ]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "Body", false, &attachments).unwrap(); + + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("a.pdf")); + assert!(raw.contains("b.csv")); + } + + #[test] + fn test_attachment_with_html_body() { + let att = Attachment { + filename: "image.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50, 0x4E, 0x47], + content_id: None, + }; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "

Hello

", true, &[att]).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(raw.contains("multipart/mixed")); + assert!(decoded.contains("text/html")); + assert!(decoded.contains("

Hello

")); + assert!(raw.contains("image.png")); + } + + #[test] + fn test_attachment_empty_produces_no_multipart() { + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "Body", false, &[]).unwrap(); + + assert!(!raw.contains("multipart/mixed")); + assert!(raw.contains("text/plain")); + } + + #[test] + fn test_parse_attachments_rejects_control_chars() { + let matches = make_attach_matches(&["test", "-a", "file0円name.pdf"]); + let err = parse_attachments(&matches).unwrap_err(); + assert!(err.to_string().contains("control characters")); + } + + #[test] + fn test_parse_attachments_rejects_directory() { + // Use a relative directory that exists in CWD + let matches = make_attach_matches(&["test", "-a", "src"]); + let err = parse_attachments(&matches).unwrap_err(); + assert!(err.to_string().contains("not a regular file")); + } + + #[test] + fn test_parse_attachments_empty_returns_empty_vec() { + let matches = make_attach_matches(&["test"]); + let attachments = parse_attachments(&matches).unwrap(); + assert!(attachments.is_empty()); + } + + #[test] + fn test_parse_attachments_reads_real_file() { + use std::io::Write; + let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); + let dir = tempfile::tempdir_in(&cwd).unwrap(); + let file_path = dir.path().join("test.txt"); + let mut f = std::fs::File::create(&file_path).unwrap(); + f.write_all(b"hello world").unwrap(); + drop(f); + + let path_str = file_path.to_str().unwrap().to_string(); + let matches = make_attach_matches(&["test", "-a", &path_str]); + let attachments = parse_attachments(&matches).unwrap(); + + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].filename, "test.txt"); + assert_eq!(attachments[0].content_type, "text/plain"); + assert_eq!(attachments[0].data, b"hello world"); + } + + #[test] + fn test_parse_attachments_nonexistent_file() { + let matches = make_attach_matches(&["test", "-a", "nonexistent_file.pdf"]); + let err = parse_attachments(&matches).unwrap_err(); + assert!( + err.to_string().contains("nonexistent_file.pdf"), + "error should include the path: {}", + err + ); + } + + #[test] + fn test_parse_attachments_unknown_extension_falls_back_to_octet_stream() { + use std::io::Write; + let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); + let dir = tempfile::tempdir_in(&cwd).unwrap(); + let file_path = dir.path().join("data.zzqqxx"); + let mut f = std::fs::File::create(&file_path).unwrap(); + f.write_all(b"unknown format").unwrap(); + drop(f); + + let path_str = file_path.to_str().unwrap().to_string(); + let matches = make_attach_matches(&["test", "-a", &path_str]); + let attachments = parse_attachments(&matches).unwrap(); + + assert_eq!(attachments[0].content_type, "application/octet-stream"); + } + + #[test] + fn test_parse_attachments_size_limit_accumulates() { + let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); + let dir = tempfile::tempdir_in(&cwd).unwrap(); + + // Create two files whose combined size exceeds MAX_TOTAL_ATTACHMENT_BYTES + let file1 = dir.path().join("big1.bin"); + let file2 = dir.path().join("big2.bin"); + // Each file is just over half the limit + let half_plus_one = (MAX_TOTAL_ATTACHMENT_BYTES / 2 + 1) as usize; + std::fs::write(&file1, vec![0u8; half_plus_one]).unwrap(); + std::fs::write(&file2, vec![0u8; half_plus_one]).unwrap(); + + let path1 = file1.to_str().unwrap().to_string(); + let path2 = file2.to_str().unwrap().to_string(); + let matches = make_attach_matches(&["test", "-a", &path1, "-a", &path2]); + let err = parse_attachments(&matches).unwrap_err(); + assert!( + err.to_string().contains("exceeds"), + "error should mention exceeding limit: {}", + err + ); + + // A single file under the limit should succeed + let matches = make_attach_matches(&["test", "-a", &path1]); + assert!(parse_attachments(&matches).is_ok()); + } + + #[test] + fn test_parse_attachments_rejects_empty_file() { + let cwd = std::env::current_dir().unwrap().canonicalize().unwrap(); + let dir = tempfile::tempdir_in(&cwd).unwrap(); + let file_path = dir.path().join("empty.txt"); + std::fs::write(&file_path, b"").unwrap(); + + let path_str = file_path.to_str().unwrap().to_string(); + let matches = make_attach_matches(&["test", "-a", &path_str]); + let err = parse_attachments(&matches).unwrap_err(); + assert!( + err.to_string().contains("empty (0 bytes)"), + "error should mention empty file: {}", + err + ); + } + + // --- resolve_sender_from_identities tests --- + + #[test] + fn test_parse_send_as_response() { + let body = serde_json::json!({ + "sendAs": [ + { + "sendAsEmail": "malo@intelligence.org", + "displayName": "Malo Bourgon", + "replyToAddress": "", + "signature": "", + "isPrimary": true, + "isDefault": true, + "treatAsAlias": false, + "verificationStatus": "accepted" + }, + { + "sendAsEmail": "malo@work.com", + "displayName": "Malo (Work)", + "replyToAddress": "", + "signature": "", + "isPrimary": false, + "isDefault": false, + "treatAsAlias": true, + "verificationStatus": "accepted" + }, + { + "sendAsEmail": "noreply@example.com", + "displayName": "", + "isPrimary": false, + "isDefault": false, + "verificationStatus": "accepted" + } + ] + }); + + let ids = parse_send_as_response(&body); + assert_eq!(ids.len(), 3); + + assert_eq!(ids[0].mailbox.email, "malo@intelligence.org"); + assert_eq!(ids[0].mailbox.name.as_deref(), Some("Malo Bourgon")); + assert!(ids[0].is_default); + + assert_eq!(ids[1].mailbox.email, "malo@work.com"); + assert_eq!(ids[1].mailbox.name.as_deref(), Some("Malo (Work)")); + assert!(!ids[1].is_default); + + // Empty displayName becomes None + assert_eq!(ids[2].mailbox.email, "noreply@example.com"); + assert!(ids[2].mailbox.name.is_none()); + assert!(!ids[2].is_default); + } + + #[test] + fn test_parse_send_as_response_empty() { + let body = serde_json::json!({}); + let ids = parse_send_as_response(&body); + assert!(ids.is_empty()); + } + + #[test] + fn test_parse_send_as_response_skips_missing_email() { + let body = serde_json::json!({ + "sendAs": [ + { "displayName": "No Email", "isDefault": true }, + { "sendAsEmail": "valid@example.com", "isDefault": false } + ] + }); + let ids = parse_send_as_response(&body); + assert_eq!(ids.len(), 1); + assert_eq!(ids[0].mailbox.email, "valid@example.com"); + } + + fn make_identities() -> Vec { + vec![ + SendAsIdentity { + mailbox: Mailbox { + name: Some("Malo Bourgon".to_string()), + email: "malo@intelligence.org".to_string(), + }, + is_default: true, + }, + SendAsIdentity { + mailbox: Mailbox { + name: Some("Malo (Work)".to_string()), + email: "malo@work.com".to_string(), + }, + is_default: false, + }, + ] + } + + #[test] + fn test_resolve_sender_no_from_returns_default() { + let ids = make_identities(); + let result = resolve_sender_from_identities(None, &ids); + let addrs = result.unwrap(); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].email, "malo@intelligence.org"); + assert_eq!(addrs[0].name.as_deref(), Some("Malo Bourgon")); + } + + #[test] + fn test_resolve_sender_bare_email_enriched() { + let ids = make_identities(); + let from = [Mailbox::parse("malo@work.com")]; + let result = resolve_sender_from_identities(Some(&from), &ids); + let addrs = result.unwrap(); + assert_eq!(addrs[0].email, "malo@work.com"); + assert_eq!(addrs[0].name.as_deref(), Some("Malo (Work)")); + } + + #[test] + fn test_resolve_sender_bare_email_case_insensitive() { + let ids = make_identities(); + let from = [Mailbox::parse("Malo@Work.Com")]; + let result = resolve_sender_from_identities(Some(&from), &ids); + let addrs = result.unwrap(); + assert_eq!(addrs[0].name.as_deref(), Some("Malo (Work)")); + } + + #[test] + fn test_resolve_sender_bare_email_not_in_list_passes_through() { + let ids = make_identities(); + let from = [Mailbox::parse("unknown@example.com")]; + let result = resolve_sender_from_identities(Some(&from), &ids); + let addrs = result.unwrap(); + assert_eq!(addrs[0].email, "unknown@example.com"); + assert!(addrs[0].name.is_none()); + } + + #[test] + fn test_resolve_sender_with_display_name_returns_as_is() { + let ids = make_identities(); + let from = [Mailbox::parse("Custom Name ")]; + let result = resolve_sender_from_identities(Some(&from), &ids); + let addrs = result.unwrap(); + assert_eq!(addrs[0].email, "malo@work.com"); + assert_eq!(addrs[0].name.as_deref(), Some("Custom Name")); + } + + #[test] + fn test_resolve_sender_mixed_enriches_only_bare() { + let ids = make_identities(); + let from = [ + Mailbox::parse("Custom "), + Mailbox::parse("malo@work.com"), + ]; + let result = resolve_sender_from_identities(Some(&from), &ids); + let addrs = result.unwrap(); + // First has explicit name — kept as-is + assert_eq!(addrs[0].name.as_deref(), Some("Custom")); + // Second was bare — enriched from send-as list + assert_eq!(addrs[1].name.as_deref(), Some("Malo (Work)")); + } + + #[test] + fn test_resolve_sender_no_default_in_list() { + let ids = vec![SendAsIdentity { + mailbox: Mailbox { + name: Some("Alias".to_string()), + email: "alias@example.com".to_string(), + }, + is_default: false, + }]; + let result = resolve_sender_from_identities(None, &ids); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_sender_empty_display_name_treated_as_none() { + let ids = vec![SendAsIdentity { + mailbox: Mailbox { + name: None, + email: "bare@example.com".to_string(), + }, + is_default: true, + }]; + let result = resolve_sender_from_identities(None, &ids); + let addrs = result.unwrap(); + assert_eq!(addrs[0].email, "bare@example.com"); + assert!(addrs[0].name.is_none()); + } + + // --- parse_profile_display_name tests --- + + #[test] + fn test_parse_profile_display_name() { + let body = serde_json::json!({ + "resourceName": "people/112118466613566642951", + "etag": "%EgUBAi43PRoEAQIFByIMR0xCc0FMcVBJQmc9", + "names": [{ + "metadata": { + "primary": true, + "source": { "type": "DOMAIN_PROFILE", "id": "112118466613566642951" } + }, + "displayName": "Malo Bourgon", + "familyName": "Bourgon", + "givenName": "Malo", + "displayNameLastFirst": "Bourgon, Malo" + }] + }); + assert_eq!( + parse_profile_display_name(&body).as_deref(), + Some("Malo Bourgon") + ); + } + + // --- Payload walker tests --- + + fn base64url(s: &str) -> String { + URL_SAFE.encode(s) + } + + #[test] + fn test_extract_payload_contents_simple() { + let text_data = base64url("Hello plain text"); + let html_data = base64url("

Hello HTML

"); + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { "mimeType": "text/plain", "body": { "data": text_data, "size": 16 } }, + { "mimeType": "text/html", "body": { "data": html_data, "size": 18 } }, + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.body_text.as_deref(), Some("Hello plain text")); + assert_eq!(contents.body_html.as_deref(), Some("

Hello HTML

")); + assert!(contents.parts.is_empty()); + } + + #[test] + fn test_extract_payload_contents_with_attachment() { + let text_data = base64url("Body text"); + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { "mimeType": "text/plain", "body": { "data": text_data, "size": 9 } }, + { + "mimeType": "application/pdf", + "filename": "report.pdf", + "body": { "attachmentId": "ATT123", "size": 1024 }, + "headers": [ + { "name": "Content-Disposition", "value": "attachment; filename=\"report.pdf\"" } + ] + } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.body_text.as_deref(), Some("Body text")); + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "report.pdf"); + assert_eq!(contents.parts[0].content_type, "application/pdf"); + assert_eq!(contents.parts[0].attachment_id, "ATT123"); + assert_eq!(contents.parts[0].size, 1024); + assert!(!contents.parts[0].is_inline()); + assert!(contents.parts[0].content_id.is_none()); + } + + #[test] + fn test_extract_payload_contents_with_inline_image() { + let text_data = base64url("Body"); + let html_data = base64url("

See

"); + let payload = json!({ + "mimeType": "multipart/related", + "parts": [ + { + "mimeType": "multipart/alternative", + "parts": [ + { "mimeType": "text/plain", "body": { "data": text_data, "size": 4 } }, + { "mimeType": "text/html", "body": { "data": html_data, "size": 40 } }, + ] + }, + { + "mimeType": "image/png", + "filename": "photo.png", + "body": { "attachmentId": "INLINE1", "size": 5000 }, + "headers": [ + { "name": "Content-ID", "value": "" }, + { "name": "Content-Disposition", "value": "inline; filename=\"photo.png\"" } + ] + } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert!(contents.parts[0].is_inline()); + assert_eq!( + contents.parts[0].content_id.as_deref(), + Some("img1@example.com") + ); + assert_eq!(contents.parts[0].filename, "photo.png"); + } + + #[test] + fn test_extract_payload_contents_no_filename_synthesis() { + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { "mimeType": "text/plain", "body": { "data": base64url("hi"), "size": 2 } }, + { + "mimeType": "image/jpeg", + "filename": "", + "body": { "attachmentId": "ATT_NO_NAME", "size": 500 }, + "headers": [] + } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "part-0.jpg"); + assert!(!contents.parts[0].is_inline()); + } + + #[test] + fn test_content_id_normalization() { + let payload = json!({ + "mimeType": "image/png", + "filename": "logo.png", + "body": { "attachmentId": "CID_TEST", "size": 100 }, + "headers": [ + { "name": "Content-ID", "value": "" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + // Angle brackets should be stripped + assert_eq!( + contents.parts[0].content_id.as_deref(), + Some("logo@company.com") + ); + } + + #[test] + fn test_content_id_crlf_injection_sanitized() { + // Content-ID is sender-controlled; CR/LF could inject MIME headers. + // Verify that control characters are stripped. + let payload = json!({ + "mimeType": "image/png", + "filename": "evil.png", + "body": { "attachmentId": "INJECT_TEST", "size": 100 }, + "headers": [ + { "name": "Content-ID", "value": "" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + // CR/LF stripped, part is still inline + assert!(contents.parts[0].is_inline()); + let cid = contents.parts[0].content_id.as_deref().unwrap(); + assert!(!cid.contains('\r')); + assert!(!cid.contains('\n')); + assert_eq!(cid, "img1@example.comX-Injected: yes"); + } + + #[test] + fn test_content_id_all_control_chars_becomes_none() { + // A Content-ID that is entirely control characters should be treated as absent, + // making the part a regular attachment instead of inline. + let payload = json!({ + "mimeType": "image/png", + "filename": "weird.png", + "body": { "attachmentId": "EMPTY_CID", "size": 100 }, + "headers": [ + { "name": "Content-ID", "value": "<\r\n>" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert!(!contents.parts[0].is_inline()); + assert!(contents.parts[0].content_id.is_none()); + } + + #[test] + fn test_parse_profile_display_name_empty() { + let body = serde_json::json!({}); + assert!(parse_profile_display_name(&body).is_none()); + } + + #[test] + fn test_parse_profile_display_name_empty_name() { + let body = serde_json::json!({ + "names": [{ "displayName": "" }] + }); + assert!(parse_profile_display_name(&body).is_none()); + } + + #[test] + fn test_parse_profile_display_name_no_names_array() { + let body = serde_json::json!({ "names": "not-an-array" }); + assert!(parse_profile_display_name(&body).is_none()); + } + + // --- build_api_error tests --- + + #[test] + fn test_build_api_error_parses_google_json_format() { + let body = r#"{"error":{"code":403,"message":"Insufficient Permission","errors":[{"reason":"insufficientPermissions","domain":"global","message":"Insufficient Permission"}]}}"#; + let err = build_api_error(403, body, "Test context"); + match err { + GwsError::Api { + code, + message, + reason, + enable_url, + } => { + assert_eq!(code, 403); + assert!(message.contains("Test context")); + assert!(message.contains("Insufficient Permission")); + assert_eq!(reason, "insufficientPermissions"); + assert!(enable_url.is_none()); + } + _ => panic!("Expected GwsError::Api"), + } + } + + #[test] + fn test_build_api_error_falls_back_to_raw_body() { + let err = build_api_error(500, "Internal Server Error", "Test context"); + match err { + GwsError::Api { + code, + message, + reason, + .. + } => { + assert_eq!(code, 500); + assert!(message.contains("Internal Server Error")); + assert_eq!(reason, "unknown"); + } + _ => panic!("Expected GwsError::Api"), + } + } + + #[test] + fn test_build_api_error_extracts_top_level_reason() { + let body = r#"{"error":{"code":404,"message":"Not Found","reason":"notFound"}}"#; + let err = build_api_error(404, body, "ctx"); + match err { + GwsError::Api { reason, .. } => assert_eq!(reason, "notFound"), + _ => panic!("Expected GwsError::Api"), + } + } + + #[test] + fn test_build_api_error_access_not_configured_extracts_url() { + let body = r#"{"error":{"code":403,"message":"People API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/people.googleapis.com/overview?project=123 then retry.","errors":[{"reason":"accessNotConfigured"}]}}"#; + let err = build_api_error(403, body, "ctx"); + match err { + GwsError::Api { + reason, enable_url, .. + } => { + assert_eq!(reason, "accessNotConfigured"); + assert!(enable_url.is_some()); + assert!(enable_url + .unwrap() + .contains("console.developers.google.com")); + } + _ => panic!("Expected GwsError::Api"), + } + } + + #[test] + fn test_attachment_with_content_id_and_disposition_attachment_is_not_inline() { + // Gmail gives Content-IDs to regular attachments (e.g., PDFs). A part + // with Content-Disposition: attachment should be classified as a regular + // attachment regardless of Content-ID presence. + let payload = json!({ + "mimeType": "application/pdf", + "filename": "report.pdf", + "body": { "attachmentId": "PDF1", "size": 50000 }, + "headers": [ + { "name": "Content-Disposition", "value": "attachment; filename=\"report.pdf\"" }, + { "name": "Content-ID", "value": "" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + // Should be classified as regular attachment, NOT inline + assert!(!contents.parts[0].is_inline()); + assert!(contents.parts[0].content_id.is_none()); + } + + #[test] + fn test_extract_payload_contents_does_not_recurse_into_attachments() { + // A message/rfc822 attachment has its own MIME subtree. The walker + // should NOT recurse into it — the attached message's body and parts + // should not leak into the top-level message. + let payload = json!({ + "mimeType": "multipart/mixed", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": base64url("Outer body"), "size": 10 } + }, + { + "mimeType": "message/rfc822", + "filename": "attached.eml", + "body": { "attachmentId": "EML1", "size": 5000 }, + "headers": [], + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": base64url("Inner body — should NOT be extracted"), "size": 40 } + }, + { + "mimeType": "application/pdf", + "filename": "inner.pdf", + "body": { "attachmentId": "INNER_ATT", "size": 1000 }, + "headers": [] + } + ] + } + ] + }); + let contents = extract_payload_contents(&payload); + // Should extract the outer body text + assert_eq!(contents.body_text.as_deref(), Some("Outer body")); + // Should have exactly one part: the message/rfc822 attachment + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "attached.eml"); + assert_eq!(contents.parts[0].attachment_id, "EML1"); + // The inner body and inner attachment should NOT appear + assert_ne!( + contents.body_text.as_deref(), + Some("Inner body \u{2014} should NOT be extracted") + ); + } + + #[test] + fn test_header_case_insensitive() { + let payload = json!({ + "mimeType": "image/gif", + "filename": "spacer.gif", + "body": { "attachmentId": "CASE_TEST", "size": 43 }, + "headers": [ + { "name": "content-id", "value": "" }, + { "name": "content-disposition", "value": "inline" } + ] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert!(contents.parts[0].is_inline()); + assert_eq!( + contents.parts[0].content_id.as_deref(), + Some("spacer@example.com") + ); + } + + #[test] + fn test_filename_control_char_sanitization() { + let payload = json!({ + "mimeType": "application/pdf", + "filename": "report\x00\x0d.pdf", + "body": { "attachmentId": "SANITIZE_TEST", "size": 100 }, + "headers": [] + }); + let contents = extract_payload_contents(&payload); + assert_eq!(contents.parts.len(), 1); + assert_eq!(contents.parts[0].filename, "report.pdf"); + } + + // --- finalize_message MIME structure tests --- + + #[test] + fn test_finalize_message_html_inline_creates_multipart_related() { + let attachments = vec![Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50, 0x4E, 0x47], + content_id: Some("img1@example.com".to_string()), + }]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message( + mb, + "

See

", + true, + &attachments, + ) + .unwrap(); + + assert!(raw.contains("multipart/related")); + assert!(raw.contains("text/html")); + assert!(raw.contains("Content-ID: ")); + // Should NOT be multipart/mixed since there are no regular attachments + assert!(!raw.contains("multipart/mixed")); + } + + #[test] + fn test_finalize_message_html_inline_and_attachment() { + let attachments = vec![ + Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("img1@example.com".to_string()), + }, + Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"pdf data".to_vec(), + content_id: None, + }, + ]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "

HTML body

", true, &attachments).unwrap(); + + // Should have multipart/mixed wrapping multipart/related + regular attachment + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(raw.contains("report.pdf")); + } + + #[test] + fn test_finalize_message_plain_text_downgrades_inline_to_attachment() { + let attachments = vec![Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("img1@example.com".to_string()), + }]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message(mb, "Plain text body", false, &attachments).unwrap(); + + // Should NOT use multipart/related in plain text mode + assert!(!raw.contains("multipart/related")); + // Should be a regular attachment + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("photo.png")); + // Content-ID should NOT appear + assert!(!raw.contains("Content-ID: ")); + } + + // --- parse_original_message end-to-end with parts --- + + #[test] + fn test_parse_original_message_populates_parts() { + let msg = json!({ + "threadId": "thread1", + "snippet": "fallback", + "payload": { + "mimeType": "multipart/mixed", + "headers": [ + { "name": "From", "value": "alice@example.com" }, + { "name": "To", "value": "bob@example.com" }, + { "name": "Subject", "value": "Files" }, + { "name": "Message-ID", "value": "" }, + ], + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": base64url("Hello"), "size": 5 } + }, + { + "mimeType": "application/pdf", + "filename": "report.pdf", + "body": { "attachmentId": "ATT1", "size": 2048 }, + "headers": [] + }, + { + "mimeType": "image/png", + "filename": "photo.png", + "body": { "attachmentId": "ATT2", "size": 4096 }, + "headers": [ + { "name": "Content-ID", "value": "" } + ] + } + ] + } + }); + let original = parse_original_message(&msg).unwrap(); + assert_eq!(original.body_text, "Hello"); + assert_eq!(original.parts.len(), 2); + // First part: regular attachment + assert_eq!(original.parts[0].filename, "report.pdf"); + assert!(!original.parts[0].is_inline()); + assert_eq!(original.parts[0].attachment_id, "ATT1"); + // Second part: inline image + assert_eq!(original.parts[1].filename, "photo.png"); + assert!(original.parts[1].is_inline()); + assert_eq!( + original.parts[1].content_id.as_deref(), + Some("img1@example.com") + ); + } + + // --- finalize_message with multiple inline images --- + + #[test] + fn test_finalize_message_html_multiple_inline_images() { + let attachments = vec![ + Attachment { + filename: "img1.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("img1@example.com".to_string()), + }, + Attachment { + filename: "img2.jpg".to_string(), + content_type: "image/jpeg".to_string(), + data: vec![0xFF, 0xD8], + content_id: Some("img2@example.com".to_string()), + }, + ]; + let mb = mail_builder::MessageBuilder::new() + .to(MbAddress::new_address(None::<&str>, "test@example.com")) + .subject("test"); + let raw = finalize_message( + mb, + "", + true, + &attachments, + ) + .unwrap(); + + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(raw.contains("Content-ID: ")); + } + + // --- synthesize_filename direct tests --- + + #[test] + fn test_synthesize_filename_jpeg() { + assert_eq!(synthesize_filename(0, "image/jpeg"), "part-0.jpg"); + } + + #[test] + fn test_synthesize_filename_svg() { + assert_eq!(synthesize_filename(1, "image/svg+xml"), "part-1.svg"); + } + + #[test] + fn test_synthesize_filename_octet_stream() { + assert_eq!( + synthesize_filename(2, "application/octet-stream"), + "part-2.bin" + ); + } + + #[test] + fn test_synthesize_filename_no_slash() { + assert_eq!(synthesize_filename(0, "weirdtype"), "part-0.bin"); + } + + // --- sanitize_remote_filename edge cases --- + + #[test] + fn test_sanitize_remote_filename_all_control_chars() { + // All control characters → falls back to synthesized name + assert_eq!( + sanitize_remote_filename("\x00\x01\x02", 0, "application/pdf"), + "part-0.pdf" + ); + } + + #[test] + fn test_sanitize_remote_filename_whitespace_only() { + assert_eq!( + sanitize_remote_filename(" ", 0, "image/png"), + "part-0.png" + ); + } +} diff --git a/crates/google-workspace-cli/src/helpers/gmail/read.rs b/crates/google-workspace-cli/src/helpers/gmail/read.rs new file mode 100644 index 00000000..c09d0858 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/gmail/read.rs @@ -0,0 +1,144 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; +use std::io::{self, Write}; + +/// Handle the `+read` subcommand. +pub(super) async fn handle_read( + _doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let message_id = matches.get_one::("id").unwrap(); + + let dry_run = matches.get_flag("dry-run"); + + let original = if dry_run { + OriginalMessage::dry_run_placeholder(message_id) + } else { + let t = auth::get_token(&[GMAIL_READONLY_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + + let client = crate::client::build_client()?; + fetch_message_metadata(&client, &t, message_id).await? + }; + + let format = matches.get_one::("format").unwrap(); + let show_headers = matches.get_flag("headers"); + let use_html = matches.get_flag("html"); + + let mut stdout = io::stdout().lock(); + + if format == "json" { + let json_output = serde_json::to_string_pretty(&original) + .context("Failed to serialize message to JSON")?; + writeln!(stdout, "{}", json_output).context("Failed to write JSON output")?; + return Ok(()); + } + + if show_headers { + // Format structured fields into display strings for header output. + let from_str = original.from.to_string(); + let to_str = format_mailbox_list(&original.to); + let cc_str = original + .cc + .as_ref() + .map(|cc| format_mailbox_list(cc)) + .unwrap_or_default(); + + let headers_to_show: [(&str, &str); 5] = [ + ("From", &from_str), + ("To", &to_str), + ("Cc", &cc_str), + ("Subject", &original.subject), + ("Date", original.date.as_deref().unwrap_or_default()), + ]; + for (name, value) in headers_to_show { + if value.is_empty() { + continue; + } + // Replace newlines to prevent header spoofing in the output, then sanitize. + let sanitized_value = sanitize_for_terminal(&value.replace(['\r', '\n'], " ")); + writeln!(stdout, "{}: {}", name, sanitized_value) + .with_context(|| format!("Failed to write '{name}' header"))?; + } + writeln!(stdout, "---").context("Failed to write header separator")?; + } + + let body = if use_html { + original + .body_html + .as_deref() + .filter(|s| !s.trim().is_empty()) + .unwrap_or(&original.body_text) + } else { + &original.body_text + }; + + writeln!(stdout, "{}", sanitize_for_terminal(body)).context("Failed to write message body")?; + + Ok(()) +} + +/// Format a slice of Mailbox as a displayable comma-separated string. +fn format_mailbox_list(mailboxes: &[Mailbox]) -> String { + mailboxes + .iter() + .map(|m| m.to_string()) + .collect::>() + .join(", ") +} + +use crate::output::sanitize_for_terminal; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_for_terminal() { + let malicious = "Subject: \x1b]0;MALICIOUS\x07Hello\nWorld\r\t"; + let sanitized = sanitize_for_terminal(malicious); + // ANSI escape sequences (control chars) should be removed + assert!(!sanitized.contains('\x1b')); + assert!(!sanitized.contains('\x07')); + // CR is also stripped (can be abused for terminal overwrite attacks) + assert!(!sanitized.contains('\r')); + // Newline and tab should be preserved + assert!(sanitized.contains("Hello")); + assert!(sanitized.contains('\n')); + assert!(sanitized.contains('\t')); + } + + #[test] + fn test_format_mailbox_list_empty() { + assert_eq!(format_mailbox_list(&[]), ""); + } + + #[test] + fn test_format_mailbox_list_single() { + let mailboxes = Mailbox::parse_list("alice@example.com"); + let result = format_mailbox_list(&mailboxes); + assert!(result.contains("alice@example.com")); + } + + #[test] + fn test_format_mailbox_list_multiple() { + let mailboxes = Mailbox::parse_list("alice@example.com, Bob "); + let result = format_mailbox_list(&mailboxes); + assert!(result.contains("alice@example.com")); + assert!(result.contains("bob@example.com")); + } +} diff --git a/crates/google-workspace-cli/src/helpers/gmail/reply.rs b/crates/google-workspace-cli/src/helpers/gmail/reply.rs new file mode 100644 index 00000000..6e5b8c21 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/gmail/reply.rs @@ -0,0 +1,1578 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +/// Handle the `+reply` and `+reply-all` subcommands. +pub(super) async fn handle_reply( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, + reply_all: bool, +) -> Result<(), GwsError> { + let mut config = parse_reply_args(matches)?; + let dry_run = matches.get_flag("dry-run"); + + let (original, token, self_email, client) = if dry_run { + ( + OriginalMessage::dry_run_placeholder(&config.message_id), + None, + None, + None, + ) + } else { + let t = auth::get_token(&[GMAIL_SCOPE]) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let c = crate::client::build_client()?; + let orig = fetch_message_metadata(&c, &t, &config.message_id).await?; + config.from = resolve_sender(&c, &t, config.from.as_deref()).await?; + // For reply-all, always fetch the primary email for self-dedup and + // self-reply detection. The resolved sender may be an alias that differs from the primary + // address — both must be excluded from recipients. from_alias_email + // (extracted from config.from below) handles the alias; self_email + // handles the primary. + let self_addr = if reply_all { + Some(fetch_user_email(&c, &t).await?) + } else { + None + }; + (orig, Some(t), self_addr, Some(c)) + }; + + let self_email = self_email.as_deref(); + + // Determine reply recipients + let from_alias_email = config + .from + .as_ref() + .and_then(|addrs| addrs.first()) + .map(|m| m.email.as_str()); + let mut reply_to = if reply_all { + build_reply_all_recipients( + &original, + config.cc.as_deref(), + config.remove.as_deref(), + self_email, + from_alias_email, + ) + } else { + Ok(ReplyRecipients { + to: extract_reply_to_address(&original), + cc: config.cc.clone(), + }) + }?; + + // Append extra --to recipients + if let Some(extra_to) = &config.extra_to { + reply_to.to.extend(extra_to.iter().cloned()); + } + + // Dedup across To/CC/BCC (priority: To> CC> BCC) + let (to, cc, bcc) = + dedup_recipients(&reply_to.to, reply_to.cc.as_deref(), config.bcc.as_deref()); + + if to.is_empty() { + return Err(GwsError::Validation( + "No To recipient remains after exclusions and --to additions".to_string(), + )); + } + + let subject = build_reply_subject(&original.subject); + let refs = build_references_chain(&original); + + let envelope = ReplyEnvelope { + to: &to, + cc: non_empty_slice(&cc), + bcc: non_empty_slice(&bcc), + from: config.from.as_deref(), + + subject: &subject, + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: &config.body, + html: config.html, + }; + + // Fetch inline images for HTML replies only. In plain-text mode, inline + // images are dropped entirely — matching Gmail web, which strips them from + // both plain-text replies and plain-text forwards. + let mut all_attachments = config.attachments; + if let (true, Some(client), Some(token)) = (config.html, &client, &token) { + let inline_parts: Vec<_> = original + .parts + .iter() + .filter(|p| p.is_inline()) + .cloned() + .collect(); + + fetch_and_merge_original_parts( + client, + token, + &config.message_id, + &inline_parts, + &mut all_attachments, + ) + .await?; + } + + let raw = create_reply_raw_message(&envelope, &original, &all_attachments)?; + + super::dispatch_raw_email( + doc, + matches, + &raw, + original.thread_id.as_deref(), + token.as_deref(), + ) + .await +} + +// --- Data structures --- + +#[derive(Debug)] +struct ReplyRecipients { + to: Vec, + cc: Option>, +} + +struct ReplyEnvelope<'a> { + to: &'a [Mailbox], + cc: Option<&'a [Mailbox]>, + bcc: Option<&'a [Mailbox]>, + from: Option<&'a [Mailbox]>, + subject: &'a str, + threading: ThreadingHeaders<'a>, + body: &'a str, // Always present: --body is required for replies + html: bool, // When true, body content is treated as HTML +} + +pub(super) struct ReplyConfig { + pub message_id: String, + pub body: String, + pub from: Option>, + pub extra_to: Option>, + pub cc: Option>, + pub bcc: Option>, + pub remove: Option>, + pub html: bool, + pub attachments: Vec, +} + +/// Fetch the authenticated user's primary email from the Gmail profile API. +/// Used in reply-all for self-dedup (excluding the user from recipients) and +/// self-reply detection (switching to original-To-based addressing). +async fn fetch_user_email(client: &reqwest::Client, token: &str) -> Result { + let resp = crate::client::send_with_retry(|| { + client + .get("https://gmail.googleapis.com/gmail/v1/users/me/profile") + .bearer_auth(token) + }) + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to fetch user profile: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "(error body unreadable)".to_string()); + return Err(super::build_api_error( + status, + &body, + "Failed to fetch user profile", + )); + } + + let profile: Value = resp + .json() + .await + .map_err(|e| GwsError::Other(anyhow::anyhow!("Failed to parse profile: {e}")))?; + + profile + .get("emailAddress") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| GwsError::Other(anyhow::anyhow!("Profile missing emailAddress"))) +} + +// --- Message construction --- + +fn extract_reply_to_address(original: &OriginalMessage) -> Vec { + match &original.reply_to { + Some(reply_to) => reply_to.clone(), + None => vec![original.from.clone()], + } +} + +fn build_reply_all_recipients( + original: &OriginalMessage, + extra_cc: Option<&[mailbox]>, + remove: Option<&[mailbox]>, + self_email: Option<&str>, + from_alias: Option<&str>, +) -> Result { + let excluded = collect_excluded_emails(remove, self_email, from_alias); + + // When replying to your own message, the original sender (you) would be + // excluded from To, leaving it empty. Gmail web handles this by using the + // original To recipients as the reply targets instead, ignoring Reply-To. + // (Gmail ignores Reply-To on self-sent messages — we approximate this by + // checking the primary address and the current From alias.) + let is_self_reply = [self_email, from_alias] + .into_iter() + .flatten() + .any(|e| original.from.email.eq_ignore_ascii_case(e)); + + let (to_candidates, mut cc_candidates) = if is_self_reply { + // Self-reply: To = original To, CC = original CC + let cc = original.cc.clone().unwrap_or_default(); + (original.to.clone(), cc) + } else { + // Normal reply: To = Reply-To or From, CC = original To + CC + let mut cc = original.to.clone(); + if let Some(orig_cc) = &original.cc { + cc.extend(orig_cc.iter().cloned()); + } + (extract_reply_to_address(original), cc) + }; + + let mut to_emails = std::collections::HashSet::new(); + let to: Vec = to_candidates + .into_iter() + .filter(|m| { + let email = m.email_lowercase(); + if email.is_empty() || excluded.contains(&email) { + return false; + } + to_emails.insert(email) + }) + .collect(); + + // Add extra CC if provided + if let Some(extra) = extra_cc { + cc_candidates.extend(extra.iter().cloned()); + } + + // Filter CC: remove To recipients, excluded addresses, and duplicates + let mut seen = std::collections::HashSet::new(); + let cc: Vec = cc_candidates + .into_iter() + .filter(|m| { + let email = m.email_lowercase(); + !email.is_empty() + && !to_emails.contains(&email) + && !excluded.contains(&email) + && seen.insert(email) + }) + .collect(); + + let cc = if cc.is_empty() { None } else { Some(cc) }; + + Ok(ReplyRecipients { to, cc }) +} + +/// Deduplicate recipients across To, CC, and BCC fields. +/// +/// Priority: To> CC> BCC. If an email appears in multiple fields, +/// it is kept only in the highest-priority field. +fn dedup_recipients( + to: &[Mailbox], + cc: Option<&[mailbox]>, + bcc: Option<&[mailbox]>, +) -> (Vec, Vec, Vec) { + use std::collections::HashSet; + + let mut seen = HashSet::new(); + let mut dedup = |mailboxes: &[Mailbox]| -> Vec { + mailboxes + .iter() + .filter(|m| { + let email = m.email_lowercase(); + !email.is_empty() && seen.insert(email) + }) + .cloned() + .collect() + }; + + let to_out = dedup(to); + let cc_out = dedup(cc.unwrap_or(&[])); + let bcc_out = dedup(bcc.unwrap_or(&[])); + + (to_out, cc_out, bcc_out) +} + +fn collect_excluded_emails( + remove: Option<&[mailbox]>, + self_email: Option<&str>, + from_alias: Option<&str>, +) -> std::collections::HashSet { + let mut excluded = std::collections::HashSet::new(); + + if let Some(remove) = remove { + excluded.extend( + remove + .iter() + .map(|m| m.email_lowercase()) + .filter(|email| !email.is_empty()), + ); + } + + // Exclude the user's own address and any --from alias + for raw in [self_email, from_alias].into_iter().flatten() { + let email = Mailbox::parse(raw).email_lowercase(); + if !email.is_empty() { + excluded.insert(email); + } + } + + excluded +} + +fn build_reply_subject(original_subject: &str) -> String { + if original_subject.to_lowercase().starts_with("re:") { + original_subject.to_string() + } else { + format!("Re: {}", original_subject) + } +} + +fn create_reply_raw_message( + envelope: &ReplyEnvelope, + original: &OriginalMessage, + attachments: &[Attachment], +) -> Result { + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(envelope.to)) + .subject(envelope.subject); + + let mb = apply_optional_headers(mb, envelope.from, envelope.cc, envelope.bcc); + let mb = set_threading_headers(mb, &envelope.threading); + + let (quoted, separator) = if envelope.html { + (format_quoted_original_html(original), "
\r\n") + } else { + (format_quoted_original(original), "\r\n\r\n") + }; + let body = format!("{}{}{}", envelope.body, separator, quoted); + + finalize_message(mb, body, envelope.html, attachments) +} + +fn format_quoted_original(original: &OriginalMessage) -> String { + let quoted_body: String = original + .body_text + .lines() + .map(|line| format!("> {}", line)) + .collect::>() + .join("\r\n"); + + let attribution = match &original.date { + Some(date) => format!("On {}, {} wrote:", date, original.from), + None => format!("{} wrote:", original.from), + }; + format!("{}\r\n{}", attribution, quoted_body) +} + +fn format_quoted_original_html(original: &OriginalMessage) -> String { + let quoted_body = resolve_html_body(original); + let sender = format_sender_for_attribution(&original.from); + + let attribution = match &original.date { + Some(date) => { + let formatted = format_date_for_attribution(date); + format!("On {}, {} wrote:", formatted, sender) + } + None => format!("{} wrote:", sender), + }; + + format!( + "
\ +
\ + {}
\ +
\ +
\ +
{}
\ +
\ +
", + attribution, quoted_body, + ) +} + +// --- Argument parsing --- + +fn parse_reply_args(matches: &ArgMatches) -> Result { + // try_get_one because +reply doesn't define --remove (only +reply-all does). + // Explicit match distinguishes "arg not defined" from unexpected errors. + let remove = match matches.try_get_one::("remove") { + Ok(val) => val + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(|s| Mailbox::parse_list(&s)) + .filter(|v| !v.is_empty()), + Err(clap::parser::MatchesError::UnknownArgument { .. }) => None, + Err(e) => { + return Err(GwsError::Other(anyhow::anyhow!( + "Unexpected error reading --remove argument: {e}" + ))) + } + }; + + Ok(ReplyConfig { + message_id: matches.get_one::("message-id").unwrap().to_string(), + body: matches.get_one::("body").unwrap().to_string(), + from: parse_optional_mailboxes(matches, "from"), + extra_to: parse_optional_mailboxes(matches, "to"), + cc: parse_optional_mailboxes(matches, "cc"), + bcc: parse_optional_mailboxes(matches, "bcc"), + remove, + html: matches.get_flag("html"), + attachments: parse_attachments(matches)?, + }) +} + +#[cfg(test)] +mod tests { + use super::super::tests::{extract_header, strip_qp_soft_breaks}; + use super::*; + + #[test] + fn test_build_reply_subject_without_prefix() { + assert_eq!(build_reply_subject("Hello"), "Re: Hello"); + } + + #[test] + fn test_build_reply_subject_with_prefix() { + assert_eq!(build_reply_subject("Re: Hello"), "Re: Hello"); + } + + #[test] + fn test_build_reply_subject_case_insensitive() { + assert_eq!(build_reply_subject("RE: Hello"), "RE: Hello"); + } + + #[test] + fn test_create_reply_raw_message_basic() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original body".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; + let envelope = ReplyEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Re: Hello", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "My reply", + html: false, + }; + let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap(); + + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("alice@example.com")); + assert!(extract_header(&raw, "Subject") + .unwrap() + .contains("Re: Hello")); + assert!(extract_header(&raw, "In-Reply-To") + .unwrap() + .contains("abc@example.com")); + assert!(raw.contains("text/plain")); + assert!(raw.contains("My reply")); + assert!(raw.contains("> Original body")); + } + + #[test] + fn test_create_reply_raw_message_with_all_optional_headers() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original body".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("carol@example.com")]; + let bcc = vec![Mailbox::parse("secret@example.com")]; + let from = Mailbox::parse_list("alias@example.com"); + let envelope = ReplyEnvelope { + to: &to, + cc: Some(&cc), + bcc: Some(&bcc), + from: Some(&from), + subject: "Re: Hello", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "Reply with all headers", + html: false, + }; + let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap(); + + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("carol@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("secret@example.com")); + assert!(extract_header(&raw, "From") + .unwrap() + .contains("alias@example.com")); + } + + #[test] + fn test_build_reply_all_recipients() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ], + cc: Some(vec![Mailbox::parse("dave@example.com")]), + subject: "Hello".to_string(), + ..Default::default() + }; + + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert_eq!(recipients.to.len(), 1); + assert_eq!(recipients.to[0].email, "alice@example.com"); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "carol@example.com")); + assert!(cc.iter().any(|m| m.email == "dave@example.com")); + // Sender should not be in CC + assert!(!cc.iter().any(|m| m.email == "alice@example.com")); + } + + #[test] + fn test_build_reply_all_with_remove() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ], + subject: "Hello".to_string(), + ..Default::default() + }; + + let remove = Mailbox::parse_list("carol@example.com"); + let recipients = + build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(!cc.iter().any(|m| m.email == "carol@example.com")); + } + + #[test] + fn test_build_reply_all_remove_primary_returns_empty_to() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + ..Default::default() + }; + + let remove = Mailbox::parse_list("alice@example.com"); + let recipients = + build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap(); + assert!(recipients.to.is_empty()); + } + + #[test] + fn test_reply_all_excludes_from_alias_from_cc() { + let original = OriginalMessage { + from: Mailbox::parse("sender@example.com"), + to: vec![ + Mailbox::parse("sales@example.com"), + Mailbox::parse("bob@example.com"), + ], + cc: Some(vec![Mailbox::parse("carol@example.com")]), + subject: "Hello".to_string(), + ..Default::default() + }; + + let recipients = build_reply_all_recipients( + &original, + None, + None, + Some("me@example.com"), + Some("sales@example.com"), + ) + .unwrap(); + let cc = recipients.cc.unwrap(); + + assert!(!cc.iter().any(|m| m.email == "sales@example.com")); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "carol@example.com")); + } + + #[test] + fn test_build_reply_all_from_alias_is_self_reply() { + // When from_alias matches original.from, this is a self-reply. + // To should be the original To recipients, not empty. + let original = OriginalMessage { + from: Mailbox::parse("sales@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + ..Default::default() + }; + + let recipients = build_reply_all_recipients( + &original, + None, + None, + Some("me@example.com"), + Some("sales@example.com"), + ) + .unwrap(); + assert_eq!(recipients.to.len(), 1); + assert_eq!(recipients.to[0].email, "bob@example.com"); + } + + fn make_reply_matches(args: &[&str]) -> ArgMatches { + let cmd = Command::new("test") + .arg(Arg::new("message-id").long("message-id")) + .arg(Arg::new("body").long("body")) + .arg(Arg::new("from").long("from")) + .arg(Arg::new("to").long("to")) + .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")) + .arg(Arg::new("remove").long("remove")) + .arg(Arg::new("html").long("html").action(ArgAction::SetTrue)) + .arg( + Arg::new("attach") + .short('a') + .long("attach") + .action(ArgAction::Append), + ) + .arg( + Arg::new("dry-run") + .long("dry-run") + .action(ArgAction::SetTrue), + ) + .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue)); + cmd.try_get_matches_from(args).unwrap() + } + + #[test] + fn test_parse_reply_args() { + let matches = make_reply_matches(&["test", "--message-id", "abc123", "--body", "My reply"]); + let config = parse_reply_args(&matches).unwrap(); + assert_eq!(config.message_id, "abc123"); + assert_eq!(config.body, "My reply"); + assert!(config.extra_to.is_none()); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); + assert!(config.remove.is_none()); + } + + #[test] + fn test_parse_reply_args_with_all_options() { + let matches = make_reply_matches(&[ + "test", + "--message-id", + "abc123", + "--body", + "Reply", + "--to", + "dave@example.com", + "--cc", + "extra@example.com", + "--bcc", + "secret@example.com", + "--remove", + "unwanted@example.com", + ]); + let config = parse_reply_args(&matches).unwrap(); + assert_eq!( + config.extra_to.as_ref().unwrap()[0].email, + "dave@example.com" + ); + assert_eq!(config.cc.as_ref().unwrap()[0].email, "extra@example.com"); + assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com"); + assert_eq!( + config.remove.as_ref().unwrap()[0].email, + "unwanted@example.com" + ); + + // Whitespace-only values become None + let matches = make_reply_matches(&[ + "test", + "--message-id", + "abc123", + "--body", + "Reply", + "--to", + " ", + "--cc", + "", + "--bcc", + " ", + ]); + let config = parse_reply_args(&matches).unwrap(); + assert!(config.extra_to.is_none()); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); + } + + #[test] + fn test_parse_reply_args_html_flag() { + let matches = make_reply_matches(&[ + "test", + "--message-id", + "abc123", + "--body", + "Bold", + "--html", + ]); + let config = parse_reply_args(&matches).unwrap(); + assert!(config.html); + + // Default is false + let matches = + make_reply_matches(&["test", "--message-id", "abc123", "--body", "Plain reply"]); + let config = parse_reply_args(&matches).unwrap(); + assert!(!config.html); + } + + #[test] + fn test_parse_reply_args_without_remove_defined() { + // Simulates +reply which doesn't define --remove (only +reply-all does). + let cmd = Command::new("test") + .arg(Arg::new("message-id").long("message-id")) + .arg(Arg::new("body").long("body")) + .arg(Arg::new("from").long("from")) + .arg(Arg::new("to").long("to")) + .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")) + .arg(Arg::new("html").long("html").action(ArgAction::SetTrue)) + .arg( + Arg::new("attach") + .short('a') + .long("attach") + .action(ArgAction::Append), + ); + let matches = cmd + .try_get_matches_from(&["test", "--message-id", "abc", "--body", "hi"]) + .unwrap(); + let config = parse_reply_args(&matches).unwrap(); + assert!(config.remove.is_none()); + } + + #[test] + fn test_extract_reply_to_address_falls_back_to_from() { + let original = OriginalMessage { + from: Mailbox::parse("Alice "), + ..Default::default() + }; + let addrs = extract_reply_to_address(&original); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].email, "alice@example.com"); + assert_eq!(addrs[0].name.as_deref(), Some("Alice")); + } + + #[test] + fn test_extract_reply_to_address_prefers_reply_to() { + let original = OriginalMessage { + from: Mailbox::parse("Alice "), + reply_to: Some(vec![Mailbox::parse("list@example.com")]), + ..Default::default() + }; + let addrs = extract_reply_to_address(&original); + assert_eq!(addrs.len(), 1); + assert_eq!(addrs[0].email, "list@example.com"); + } + + #[test] + fn test_remove_does_not_match_substring() { + let original = OriginalMessage { + from: Mailbox::parse("sender@example.com"), + to: vec![ + Mailbox::parse("ann@example.com"), + Mailbox::parse("joann@example.com"), + ], + ..Default::default() + }; + let remove = Mailbox::parse_list("ann@example.com"); + let recipients = + build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap(); + let cc = recipients.cc.unwrap(); + // joann@example.com should remain, ann@example.com should be removed + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "joann@example.com"); + } + + #[test] + fn test_reply_all_uses_reply_to_for_to() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + reply_to: Some(vec![Mailbox::parse("list@example.com")]), + to: vec![Mailbox::parse("bob@example.com")], + ..Default::default() + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert_eq!(recipients.to[0].email, "list@example.com"); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + // list@example.com is in To, should not duplicate in CC + assert!(!cc.iter().any(|m| m.email == "list@example.com")); + } + + #[test] + fn test_sender_with_display_name_excluded_from_cc() { + let original = OriginalMessage { + from: Mailbox::parse("Alice "), + to: vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert_eq!(recipients.to[0].email, "alice@example.com"); + let cc = recipients.cc.unwrap(); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "bob@example.com"); + } + + #[test] + fn test_remove_with_display_name_format() { + let original = OriginalMessage { + from: Mailbox::parse("sender@example.com"), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ], + ..Default::default() + }; + let remove = Mailbox::parse_list("Carol "); + let recipients = + build_reply_all_recipients(&original, None, Some(&remove), None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "bob@example.com"); + } + + #[test] + fn test_reply_all_with_extra_cc() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + ..Default::default() + }; + let extra_cc = Mailbox::parse_list("extra@example.com"); + let recipients = + build_reply_all_recipients(&original, Some(&extra_cc), None, None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "extra@example.com")); + } + + #[test] + fn test_reply_all_cc_none_when_all_filtered() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("alice@example.com")], + ..Default::default() + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert!(recipients.cc.is_none()); + } + + #[test] + fn test_case_insensitive_sender_exclusion() { + let original = OriginalMessage { + from: Mailbox::parse("Alice@Example.COM"), + to: vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert_eq!(cc.len(), 1); + assert_eq!(cc[0].email, "bob@example.com"); + } + + #[test] + fn test_reply_all_multi_address_reply_to_deduplicates_cc() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + reply_to: Some(vec![ + Mailbox::parse("list@example.com"), + Mailbox::parse("owner@example.com"), + ]), + to: vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("list@example.com"), + ], + cc: Some(vec![ + Mailbox::parse("owner@example.com"), + Mailbox::parse("dave@example.com"), + ]), + ..Default::default() + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + assert_eq!(recipients.to.len(), 2); + assert_eq!(recipients.to[0].email, "list@example.com"); + assert_eq!(recipients.to[1].email, "owner@example.com"); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(cc.iter().any(|m| m.email == "dave@example.com")); + assert!(!cc.iter().any(|m| m.email == "list@example.com")); + assert!(!cc.iter().any(|m| m.email == "owner@example.com")); + } + + #[test] + fn test_reply_all_with_quoted_comma_display_name() { + let original = OriginalMessage { + from: Mailbox::parse("sender@example.com"), + to: Mailbox::parse_list(r#""Doe, John" , alice@example.com"#), + ..Default::default() + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "john@example.com")); + assert!(cc.iter().any(|m| m.email == "alice@example.com")); + } + + #[test] + fn test_remove_with_quoted_comma_display_name() { + let original = OriginalMessage { + from: Mailbox::parse("sender@example.com"), + to: Mailbox::parse_list(r#""Doe, John" , alice@example.com"#), + ..Default::default() + }; + let remove = Mailbox::parse_list("john@example.com"); + let recipients = build_reply_all_recipients(&original, None, Some(&remove), None, None); + let cc = recipients.unwrap().cc.unwrap(); + assert!(!cc.iter().any(|m| m.email == "john@example.com")); + assert!(cc.iter().any(|m| m.email == "alice@example.com")); + } + + #[test] + fn test_reply_all_excludes_self_email() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("me@example.com"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() + }; + let recipients = + build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) + .unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(!cc.iter().any(|m| m.email == "me@example.com")); + } + + #[test] + fn test_reply_all_excludes_self_case_insensitive() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![ + Mailbox::parse("Me@Example.COM"), + Mailbox::parse("bob@example.com"), + ], + ..Default::default() + }; + let recipients = + build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) + .unwrap(); + let cc = recipients.cc.unwrap(); + assert!(cc.iter().any(|m| m.email == "bob@example.com")); + assert!(!cc.iter().any(|m| m.email_lowercase() == "me@example.com")); + } + + #[test] + fn test_reply_all_deduplicates_cc() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + cc: Some(vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ]), + ..Default::default() + }; + let recipients = build_reply_all_recipients(&original, None, None, None, None).unwrap(); + let cc = recipients.cc.unwrap(); + assert_eq!( + cc.iter().filter(|m| m.email == "bob@example.com").count(), + 1 + ); + assert!(cc.iter().any(|m| m.email == "carol@example.com")); + } + + // --- self-reply tests --- + + #[test] + fn test_reply_all_to_own_message_puts_original_to_in_to() { + let original = OriginalMessage { + from: Mailbox::parse("me@example.com"), + to: vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ], + cc: Some(vec![Mailbox::parse("carol@example.com")]), + ..Default::default() + }; + let recipients = + build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) + .unwrap(); + // To should be the original To recipients, not the original sender + assert_eq!(recipients.to.len(), 2); + assert!(recipients.to.iter().any(|m| m.email == "alice@example.com")); + assert!(recipients.to.iter().any(|m| m.email == "bob@example.com")); + // CC should be the original CC + let cc = recipients.cc.unwrap(); + assert_eq!(cc.len(), 1); + assert!(cc.iter().any(|m| m.email == "carol@example.com")); + } + + #[test] + fn test_reply_all_to_own_message_detected_via_alias() { + let original = OriginalMessage { + from: Mailbox::parse("alias@work.com"), + to: vec![Mailbox::parse("alice@example.com")], + ..Default::default() + }; + // self_email is primary, from_alias matches the original sender + let recipients = build_reply_all_recipients( + &original, + None, + None, + Some("me@gmail.com"), + Some("alias@work.com"), + ) + .unwrap(); + assert_eq!(recipients.to.len(), 1); + assert_eq!(recipients.to[0].email, "alice@example.com"); + } + + #[test] + fn test_reply_all_to_own_message_excludes_self_from_original_to() { + // You sent to yourself + Alice (e.g. a note-to-self CC'd to someone) + let original = OriginalMessage { + from: Mailbox::parse("me@example.com"), + to: vec![ + Mailbox::parse("me@example.com"), + Mailbox::parse("alice@example.com"), + ], + ..Default::default() + }; + let recipients = + build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) + .unwrap(); + // Self should still be excluded from To + assert_eq!(recipients.to.len(), 1); + assert_eq!(recipients.to[0].email, "alice@example.com"); + } + + #[test] + fn test_reply_all_to_own_message_ignores_reply_to() { + // Gmail web ignores Reply-To on self-sent messages. Verify that + // self-reply uses original.to, not Reply-To. + let original = OriginalMessage { + from: Mailbox::parse("me@example.com"), + to: vec![Mailbox::parse("alice@example.com")], + reply_to: Some(vec![Mailbox::parse("list@example.com")]), + ..Default::default() + }; + let recipients = + build_reply_all_recipients(&original, None, None, Some("me@example.com"), None) + .unwrap(); + assert_eq!(recipients.to.len(), 1); + assert_eq!(recipients.to[0].email, "alice@example.com"); + // No CC — Reply-To address should not appear anywhere + assert!(recipients.cc.is_none()); + } + + // --- dedup_recipients tests --- + + #[test] + fn test_dedup_no_overlap() { + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![Mailbox::parse("carol@example.com")]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(cc_out[0].email, "bob@example.com"); + assert_eq!(bcc_out[0].email, "carol@example.com"); + } + + #[test] + fn test_dedup_to_wins_over_cc() { + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(cc_out.len(), 1); + assert_eq!(cc_out[0].email, "bob@example.com"); + } + + #[test] + fn test_dedup_to_wins_over_bcc() { + let to = vec![Mailbox::parse("alice@example.com")]; + let bcc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("carol@example.com"), + ]; + let (to_out, _, bcc_out) = dedup_recipients(&to, None, Some(&bcc)); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(bcc_out.len(), 1); + assert_eq!(bcc_out[0].email, "carol@example.com"); + } + + #[test] + fn test_dedup_cc_wins_over_bcc() { + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![ + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ]; + let (_, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(cc_out[0].email, "bob@example.com"); + assert_eq!(bcc_out.len(), 1); + assert_eq!(bcc_out[0].email, "carol@example.com"); + } + + #[test] + fn test_dedup_all_three_overlap() { + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let bcc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + Mailbox::parse("carol@example.com"), + ]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(cc_out[0].email, "bob@example.com"); + assert_eq!(bcc_out[0].email, "carol@example.com"); + } + + #[test] + fn test_dedup_case_insensitive() { + let to = vec![Mailbox::parse("Alice@Example.COM")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None); + assert_eq!(to_out[0].email, "Alice@Example.COM"); + assert_eq!(cc_out.len(), 1); + assert_eq!(cc_out[0].email, "bob@example.com"); + } + + #[test] + fn test_dedup_bcc_fully_overlaps_returns_empty() { + let to = vec![Mailbox::parse("alice@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (_, _, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert!(bcc_out.is_empty()); + } + + #[test] + fn test_dedup_with_display_names() { + let to = vec![Mailbox::parse("Alice ")]; + let cc = vec![ + Mailbox::parse("alice@example.com"), + Mailbox::parse("bob@example.com"), + ]; + let (to_out, cc_out, _) = dedup_recipients(&to, Some(&cc), None); + assert_eq!(to_out[0].email, "alice@example.com"); + assert_eq!(to_out[0].name.as_deref(), Some("Alice")); + assert_eq!(cc_out.len(), 1); + assert_eq!(cc_out[0].email, "bob@example.com"); + } + + #[test] + fn test_dedup_intro_pattern() { + let to = vec![Mailbox::parse("bob@example.com")]; + let cc = vec![Mailbox::parse("bob@example.com")]; + let bcc = vec![Mailbox::parse("alice@example.com")]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, Some(&cc), Some(&bcc)); + assert_eq!(to_out[0].email, "bob@example.com"); + assert!(cc_out.is_empty()); + assert_eq!(bcc_out[0].email, "alice@example.com"); + } + + #[test] + fn test_dedup_simple_reply_no_cc_bcc() { + let to = vec![Mailbox::parse("alice@example.com")]; + let (to_out, cc_out, bcc_out) = dedup_recipients(&to, None, None); + assert_eq!(to_out.len(), 1); + assert_eq!(to_out[0].email, "alice@example.com"); + assert!(cc_out.is_empty()); + assert!(bcc_out.is_empty()); + } + + // --- format_quoted_original (plain text) --- + + #[test] + fn test_format_quoted_original() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Line one\nLine two\nLine three".to_string(), + ..Default::default() + }; + let quoted = format_quoted_original(&original); + assert!(quoted.contains("On Mon, 1 Jan 2026 00:00:00 +0000, alice@example.com wrote:")); + assert!(quoted.contains("> Line one")); + assert!(quoted.contains("> Line two")); + assert!(quoted.contains("> Line three")); + } + + #[test] + fn test_format_quoted_original_empty_body() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026".to_string()), + ..Default::default() + }; + let quoted = format_quoted_original(&original); + assert!(quoted.contains("alice@example.com wrote:")); + // Empty body produces no quoted lines + assert!(quoted.ends_with("wrote:\r\n")); + } + + #[test] + fn test_format_quoted_original_missing_date() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: None, + body_text: "Hello".to_string(), + ..Default::default() + }; + let quoted = format_quoted_original(&original); + assert!(quoted.starts_with("alice@example.com wrote:")); + assert!(!quoted.contains("On ")); + assert!(quoted.contains("> Hello")); + } + + // --- end-to-end --to behavioral tests --- + + #[test] + fn test_extra_to_appears_in_raw_message() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("me@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original".to_string(), + ..Default::default() + }; + + let mut to = extract_reply_to_address(&original); + to.push(Mailbox::parse("dave@example.com")); + + let (to, cc, bcc) = dedup_recipients(&to, None, None); + + let refs = build_references_chain(&original); + let envelope = ReplyEnvelope { + to: &to, + cc: non_empty_slice(&cc), + bcc: non_empty_slice(&bcc), + from: None, + subject: "Re: Hello", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "Adding Dave", + html: false, + }; + let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap(); + + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("alice@example.com")); + assert!(to_header.contains("dave@example.com")); + } + + #[test] + fn test_intro_pattern_raw_message() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("me@example.com")], + cc: Some(vec![Mailbox::parse("bob@example.com")]), + subject: "Intro".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Meet Bob".to_string(), + ..Default::default() + }; + + // build_reply_all_recipients with --remove alice, self=me + let remove = Mailbox::parse_list("alice@example.com"); + let recipients = build_reply_all_recipients( + &original, + None, + Some(&remove), + Some("me@example.com"), + None, + ) + .unwrap(); + + // To is empty (alice removed) + assert!(recipients.to.is_empty()); + + // Append --to bob + let to = vec![Mailbox::parse("bob@example.com")]; + + // Dedup with --bcc alice + let bcc = vec![Mailbox::parse("alice@example.com")]; + let (to, cc, bcc) = dedup_recipients(&to, recipients.cc.as_deref(), Some(&bcc)); + + let refs = build_references_chain(&original); + let envelope = ReplyEnvelope { + to: &to, + cc: non_empty_slice(&cc), + bcc: non_empty_slice(&bcc), + from: None, + subject: "Re: Intro", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "Hi Bob, nice to meet you!", + html: false, + }; + let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap(); + + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("bob@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("alice@example.com")); + assert!(raw.contains("Hi Bob, nice to meet you!")); + } + + // --- HTML mode tests --- + + #[test] + fn test_format_quoted_original_html_with_html_body() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "plain fallback".to_string(), + body_html: Some("

Rich content

".to_string()), + ..Default::default() + }; + let html = format_quoted_original_html(&original); + assert!(html.contains("gmail_quote")); + assert!(html.contains("Rich content

")); + assert!(!html.contains("plain fallback")); + assert!( + html.contains("alice@example.com wrote:") + ); + } + + #[test] + fn test_format_quoted_original_html_fallback_plain_text() { + let original = OriginalMessage { + from: Mailbox::parse("alice@example.com"), + date: Some("Mon, 1 Jan 2026".to_string()), + body_text: "Line one & \nLine two".to_string(), + ..Default::default() + }; + let html = format_quoted_original_html(&original); + assert!(html.contains("gmail_quote")); + assert!(html.contains("")); + assert!(html.contains("Line two")); + } + + #[test] + fn test_format_quoted_original_html_escapes_metadata() { + let original = OriginalMessage { + from: Mailbox::parse("O'Brien & Associates "), + date: Some("Jan 1 <2026>".to_string()), + body_text: "text".to_string(), + ..Default::default() + }; + let html = format_quoted_original_html(&original); + assert!(html.contains("O'Brien & Associates")); + assert!(html.contains("<ob@example.com>")); + assert!(html.contains("Jan 1 <2026>")); + } + + #[test] + fn test_create_reply_raw_message_html() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original body".to_string(), + body_html: Some("

Original

".to_string()), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; + let envelope = ReplyEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Re: Hello", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "

My HTML reply

", + html: true, + }; + let raw = create_reply_raw_message(&envelope, &original, &[]).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("alice@example.com")); + assert!(decoded.contains("

My HTML reply

")); + assert!(decoded.contains("gmail_quote")); + assert!(decoded.contains("

Original

")); + } + + #[test] + fn test_create_reply_raw_message_with_attachment() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Hello".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "Original body".to_string(), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; + let envelope = ReplyEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Re: Hello", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "See attached notes", + html: false, + }; + let attachments = vec![Attachment { + filename: "notes.txt".to_string(), + content_type: "text/plain".to_string(), + data: b"some notes".to_vec(), + content_id: None, + }]; + let raw = create_reply_raw_message(&envelope, &original, &attachments).unwrap(); + + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("notes.txt")); + assert!(raw.contains("See attached notes")); + assert!(raw.contains("> Original body")); + } + + #[test] + fn test_create_reply_raw_message_html_with_inline_image() { + let original = OriginalMessage { + thread_id: Some("t1".to_string()), + message_id: "abc@example.com".to_string(), + from: Mailbox::parse("alice@example.com"), + to: vec![Mailbox::parse("bob@example.com")], + subject: "Photo".to_string(), + date: Some("Mon, 1 Jan 2026 00:00:00 +0000".to_string()), + body_text: "See photo".to_string(), + body_html: Some("

See

".to_string()), + ..Default::default() + }; + + let refs = build_references_chain(&original); + let to = vec![Mailbox::parse("alice@example.com")]; + let envelope = ReplyEnvelope { + to: &to, + cc: None, + bcc: None, + from: None, + subject: "Re: Photo", + threading: ThreadingHeaders { + in_reply_to: &original.message_id, + references: &refs, + }, + body: "Nice photo!", + html: true, + }; + let attachments = vec![Attachment { + filename: "photo.png".to_string(), + content_type: "image/png".to_string(), + data: vec![0x89, 0x50], + content_id: Some("photo@example.com".to_string()), + }]; + let raw = create_reply_raw_message(&envelope, &original, &attachments).unwrap(); + + // Should produce multipart/related for inline image in HTML reply + assert!(raw.contains("multipart/related")); + assert!(raw.contains("Content-ID: ")); + assert!(!raw.contains("multipart/mixed")); + } +} diff --git a/crates/google-workspace-cli/src/helpers/gmail/send.rs b/crates/google-workspace-cli/src/helpers/gmail/send.rs new file mode 100644 index 00000000..c40e6480 --- /dev/null +++ b/crates/google-workspace-cli/src/helpers/gmail/send.rs @@ -0,0 +1,461 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +/// Handle the `+send` subcommand. +pub(super) async fn handle_send( + doc: &crate::discovery::RestDescription, + matches: &ArgMatches, +) -> Result<(), GwsError> { + let mut config = parse_send_args(matches)?; + let dry_run = matches.get_flag("dry-run"); + + let token = if dry_run { + None + } else { + // Resolve the target method (send or draft) and use its discovery + // doc scopes, so the token matches the operation. resolve_sender + // gracefully degrades if the token doesn't cover the sendAs.list + // endpoint. + let method = super::resolve_mail_method(doc, matches.get_flag("draft"))?; + let scopes: Vec<&str> = method.scopes.iter().map(|s| s.as_str()).collect(); + let t = auth::get_token(&scopes) + .await + .map_err(|e| GwsError::Auth(format!("Gmail auth failed: {e}")))?; + let client = crate::client::build_client()?; + config.from = resolve_sender(&client, &t, config.from.as_deref()).await?; + Some(t) + }; + + let raw = create_send_raw_message(&config)?; + + super::dispatch_raw_email(doc, matches, &raw, None, token.as_deref()).await +} + +pub(super) struct SendConfig { + pub to: Vec, + pub subject: String, + pub body: String, + pub from: Option>, + pub cc: Option>, + pub bcc: Option>, + pub html: bool, + pub attachments: Vec, +} + +fn create_send_raw_message(config: &SendConfig) -> Result { + let mb = mail_builder::MessageBuilder::new() + .to(to_mb_address_list(&config.to)) + .subject(&config.subject); + + let mb = apply_optional_headers( + mb, + config.from.as_deref(), + config.cc.as_deref(), + config.bcc.as_deref(), + ); + + finalize_message(mb, &config.body, config.html, &config.attachments) +} + +fn parse_send_args(matches: &ArgMatches) -> Result { + let to = Mailbox::parse_list(matches.get_one::("to").unwrap()); + if to.is_empty() { + return Err(GwsError::Validation( + "--to must specify at least one recipient".to_string(), + )); + } + Ok(SendConfig { + to, + subject: matches.get_one::("subject").unwrap().to_string(), + body: matches.get_one::("body").unwrap().to_string(), + from: parse_optional_mailboxes(matches, "from"), + cc: parse_optional_mailboxes(matches, "cc"), + bcc: parse_optional_mailboxes(matches, "bcc"), + html: matches.get_flag("html"), + attachments: parse_attachments(matches)?, + }) +} + +#[cfg(test)] +mod tests { + use super::super::tests::{extract_header, strip_qp_soft_breaks}; + use super::*; + + fn make_matches_send(args: &[&str]) -> ArgMatches { + let cmd = Command::new("test") + .arg(Arg::new("to").long("to")) + .arg(Arg::new("subject").long("subject")) + .arg(Arg::new("body").long("body")) + .arg(Arg::new("from").long("from")) + .arg(Arg::new("cc").long("cc")) + .arg(Arg::new("bcc").long("bcc")) + .arg(Arg::new("html").long("html").action(ArgAction::SetTrue)) + .arg( + Arg::new("attach") + .long("attach") + .short('a') + .action(ArgAction::Append), + ) + .arg(Arg::new("draft").long("draft").action(ArgAction::SetTrue)); + cmd.try_get_matches_from(args).unwrap() + } + + #[test] + fn test_parse_send_args() { + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + ]); + let config = parse_send_args(&matches).unwrap(); + assert_eq!(config.to.len(), 1); + assert_eq!(config.to[0].email, "me@example.com"); + assert_eq!(config.subject, "Hi"); + assert_eq!(config.body, "Body"); + assert!(config.from.is_none()); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); + } + + #[test] + fn test_parse_send_args_with_from() { + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--from", + "alias@example.com", + ]); + let config = parse_send_args(&matches).unwrap(); + assert_eq!(config.from.as_ref().unwrap()[0].email, "alias@example.com"); + + // Whitespace-only --from becomes None + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--from", + " ", + ]); + let config = parse_send_args(&matches).unwrap(); + assert!(config.from.is_none()); + } + + #[test] + fn test_parse_send_args_with_cc_and_bcc() { + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--cc", + "carol@example.com", + "--bcc", + "secret@example.com", + ]); + let config = parse_send_args(&matches).unwrap(); + assert_eq!(config.cc.as_ref().unwrap()[0].email, "carol@example.com"); + assert_eq!(config.bcc.as_ref().unwrap()[0].email, "secret@example.com"); + + // Whitespace-only values become None + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Body", + "--cc", + " ", + "--bcc", + "", + ]); + let config = parse_send_args(&matches).unwrap(); + assert!(config.cc.is_none()); + assert!(config.bcc.is_none()); + } + + #[test] + fn test_parse_send_args_html_flag() { + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Bold", + "--html", + ]); + let config = parse_send_args(&matches).unwrap(); + assert!(config.html); + + // Default is false + let matches = make_matches_send(&[ + "test", + "--to", + "me@example.com", + "--subject", + "Hi", + "--body", + "Plain", + ]); + let config = parse_send_args(&matches).unwrap(); + assert!(!config.html); + } + + #[test] + fn test_parse_send_args_empty_to_returns_error() { + let matches = make_matches_send(&["test", "--to", "", "--subject", "Hi", "--body", "Body"]); + let err = parse_send_args(&matches).err().unwrap(); + assert!( + err.to_string().contains("--to"), + "error should mention --to" + ); + } + + #[test] + fn test_send_html_raw_message() { + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "HTML test".to_string(), + body: "

Hello world

".to_string(), + from: None, + cc: None, + bcc: None, + html: true, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + let decoded = strip_qp_soft_breaks(&raw); + + assert!(decoded.contains("text/html")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("bob@example.com")); + assert!(extract_header(&raw, "Subject") + .unwrap() + .contains("HTML test")); + assert!(decoded.contains("

Hello world

")); + assert!(extract_header(&raw, "Cc").is_none()); + } + + #[test] + fn test_send_plain_text_raw_message() { + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "Hello".to_string(), + body: "World".to_string(), + from: None, + cc: None, + bcc: None, + html: false, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("bob@example.com")); + assert!(extract_header(&raw, "Subject").unwrap().contains("Hello")); + assert!(raw.contains("text/plain")); + assert!(raw.contains("World")); + } + + #[test] + fn test_send_with_cc_and_bcc() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: None, + cc: Some(Mailbox::parse_list("carol@example.com")), + bcc: Some(Mailbox::parse_list("secret@example.com")), + html: false, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "To") + .unwrap() + .contains("alice@example.com")); + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("carol@example.com")); + assert!(extract_header(&raw, "Bcc") + .unwrap() + .contains("secret@example.com")); + // Verify no leakage between headers + assert!(!extract_header(&raw, "To") + .unwrap() + .contains("carol@example.com")); + assert!(!extract_header(&raw, "To") + .unwrap() + .contains("secret@example.com")); + } + + #[test] + fn test_send_with_from() { + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: Some(Mailbox::parse_list("alias@example.com")), + cc: None, + bcc: None, + html: false, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "From") + .unwrap() + .contains("alias@example.com")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("bob@example.com")); + } + + #[test] + fn test_send_without_from_has_no_from_header() { + let config = SendConfig { + to: Mailbox::parse_list("bob@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: None, + cc: None, + bcc: None, + html: false, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(extract_header(&raw, "From").is_none()); + } + + #[test] + fn test_send_multiple_to_recipients() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com, bob@example.com"), + subject: "Group".to_string(), + body: "Hi all".to_string(), + from: None, + cc: None, + bcc: None, + html: false, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + let to_header = extract_header(&raw, "To").unwrap(); + assert!(to_header.contains("alice@example.com")); + assert!(to_header.contains("bob@example.com")); + } + + #[test] + fn test_send_crlf_injection_in_from_does_not_create_header() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: Some(Mailbox::parse_list( + "sender@example.com\r\nBcc: evil@attacker.com", + )), + cc: None, + bcc: None, + html: false, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + + // The CRLF injection should not create a Bcc header + assert!( + extract_header(&raw, "Bcc").is_none(), + "CRLF injection via --from should not create Bcc header" + ); + // The From header should contain the sanitized email + assert!(extract_header(&raw, "From") + .unwrap() + .contains("sender@example.com")); + } + + #[test] + fn test_send_crlf_injection_in_cc_does_not_create_header() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com"), + subject: "Test".to_string(), + body: "Body".to_string(), + from: None, + cc: Some(Mailbox::parse_list("carol@example.com\r\nX-Injected: yes")), + bcc: None, + html: false, + attachments: vec![], + }; + let raw = create_send_raw_message(&config).unwrap(); + + // CRLF stripped → "X-Injected: yes" is concatenated into the email, + // not emitted as a separate header line + assert!( + extract_header(&raw, "X-Injected").is_none(), + "CRLF injection via --cc should not create X-Injected header" + ); + assert!(extract_header(&raw, "Cc") + .unwrap() + .contains("carol@example.com")); + } + + #[test] + fn test_send_with_attachment_produces_multipart() { + let config = SendConfig { + to: Mailbox::parse_list("alice@example.com"), + subject: "Report".to_string(), + body: "See attached".to_string(), + from: None, + cc: None, + bcc: None, + html: false, + attachments: vec![Attachment { + filename: "report.pdf".to_string(), + content_type: "application/pdf".to_string(), + data: b"fake pdf".to_vec(), + content_id: None, + }], + }; + let raw = create_send_raw_message(&config).unwrap(); + + assert!(raw.contains("multipart/mixed")); + assert!(raw.contains("report.pdf")); + assert!(raw.contains("See attached")); + assert!(extract_header(&raw, "To") + .unwrap() + .contains("alice@example.com")); + } +} diff --git a/src/helpers/gmail/triage.rs b/crates/google-workspace-cli/src/helpers/gmail/triage.rs similarity index 91% rename from src/helpers/gmail/triage.rs rename to crates/google-workspace-cli/src/helpers/gmail/triage.rs index ec23bfba..3c275e1f 100644 --- a/src/helpers/gmail/triage.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/triage.rs @@ -76,13 +76,13 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { let messages = match list_json.get("messages").and_then(|m| m.as_array()) { Some(m) => m, None => { - println!("No messages found matching query: {query}"); + eprintln!("{}", no_messages_msg(query)); return Ok(()); } }; if messages.is_empty() { - println!("No messages found matching query: {query}"); + eprintln!("{}", no_messages_msg(query)); return Ok(()); } @@ -178,8 +178,18 @@ pub async fn handle_triage(matches: &ArgMatches) -> Result<(), GwsError> { Ok(()) } +/// Returns the human-readable "no messages" diagnostic string. +/// Extracted so the test can reference the exact same message without duplication. +fn no_messages_msg(query: &str) -> String { + format!( + "No messages found matching query: {}", + crate::output::sanitize_for_terminal(query) + ) +} + #[cfg(test)] mod tests { + use super::no_messages_msg; use clap::{Arg, ArgAction, Command}; /// Build a clap command matching the +triage definition so we can @@ -283,4 +293,13 @@ mod tests { .unwrap_or(crate::formatter::OutputFormat::Table); assert!(matches!(fmt, crate::formatter::OutputFormat::Json)); } + + #[test] + fn empty_result_message_is_not_json() { + // Verify that no_messages_msg() produces a human-readable string that + // belongs on stderr, not stdout. If it were valid JSON it could corrupt + // pipe workflows like `gws gmail +triage | jq`. + let msg = no_messages_msg("label:inbox"); + assert!(serde_json::from_str::(&msg).is_err()); + } } diff --git a/src/helpers/gmail/watch.rs b/crates/google-workspace-cli/src/helpers/gmail/watch.rs similarity index 96% rename from src/helpers/gmail/watch.rs rename to crates/google-workspace-cli/src/helpers/gmail/watch.rs index 15bc9889..027446fd 100644 --- a/src/helpers/gmail/watch.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/watch.rs @@ -1,6 +1,8 @@ use super::*; use crate::auth::AccessTokenProvider; use crate::helpers::PUBSUB_API_BASE; +use crate::output::colorize; +use crate::output::sanitize_for_terminal; const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1"; @@ -99,7 +101,7 @@ pub(super) async fn handle_watch( " --member=serviceAccount:gmail-api-push@system.gserviceaccount.com \\" ); eprintln!(" --role=roles/pubsub.publisher"); - eprintln!("Error: {body}"); + eprintln!("Error: {}", sanitize_for_terminal(&body)); } t @@ -286,8 +288,8 @@ async fn watch_pull_loop( Err(e) => return Err(GwsError::Other(anyhow::anyhow!("Pub/Sub pull failed: {e}"))), } } - _ = tokio::signal::ctrl_c() => { - eprintln!("\nReceived interrupt, stopping..."); + _ = super::super::shutdown_signal() => { + eprintln!("\nReceived shutdown signal, stopping..."); return Ok(()); } }; @@ -346,8 +348,8 @@ async fn watch_pull_loop( tokio::select! { _ = tokio::time::sleep(std::time::Duration::from_secs(config.poll_interval)) => {}, - _ = tokio::signal::ctrl_c() => { - eprintln!("\nReceived interrupt, stopping..."); + _ = super::super::shutdown_signal() => { + eprintln!("\nReceived shutdown signal, stopping..."); break; } } @@ -453,7 +455,11 @@ async fn fetch_and_output_messages( } } Err(e) => { - eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor sanitization failed for message {msg_id}: {e}"); + eprintln!( + "{} Model Armor sanitization failed for message {msg_id}: {}", + colorize("warning:", "33"), + sanitize_for_terminal(&e.to_string()) + ); } } } @@ -466,7 +472,11 @@ async fn fetch_and_output_messages( crate::validate::encode_path_segment(&msg_id) )); if let Err(e) = std::fs::write(&path, &json_str) { - eprintln!("Warning: failed to write {}: {e}", path.display()); + eprintln!( + "Warning: failed to write {}: {}", + path.display(), + sanitize_for_terminal(&e.to_string()) + ); } else { eprintln!("Wrote {}", path.display()); } @@ -493,12 +503,16 @@ fn apply_sanitization_result( match sanitize_config.mode { crate::helpers::modelarmor::SanitizeMode::Block => { eprintln!( - "\x1b[31m[BLOCKED]\x1b[0m Message {msg_id} blocked by Model Armor (match found)" + "{} Message {msg_id} blocked by Model Armor (match found)", + colorize("blocked:", "31") ); return None; } crate::helpers::modelarmor::SanitizeMode::Warn => { - eprintln!("\x1b[33m[WARNING]\x1b[0m Model Armor match found in message {msg_id}"); + eprintln!( + "{} Model Armor match found in message {msg_id}", + colorize("warning:", "33") + ); full_msg["_sanitization"] = serde_json::json!({ "filterMatchState": result.filter_match_state, "filterResults": result.filter_results, diff --git a/src/helpers/mod.rs b/crates/google-workspace-cli/src/helpers/mod.rs similarity index 55% rename from src/helpers/mod.rs rename to crates/google-workspace-cli/src/helpers/mod.rs index 72d31272..48ae881b 100644 --- a/src/helpers/mod.rs +++ b/crates/google-workspace-cli/src/helpers/mod.rs @@ -33,6 +33,63 @@ pub mod workflows; /// is defined in a single place. pub(crate) const PUBSUB_API_BASE: &str = "https://pubsub.googleapis.com/v1"; +/// Returns a future that completes when a shutdown signal is received. +/// +/// On Unix this listens for both SIGINT (Ctrl+C) and SIGTERM; on other +/// platforms only SIGINT is handled. Used by long-running pull loops +/// (`gmail::watch`, `events::subscribe`) to exit cleanly under container +/// orchestrators (Kubernetes, Docker, systemd) that send SIGTERM. +/// +/// The signal handler is registered once in a background task on first call +/// so it remains active for the lifetime of the process — no gap between +/// loop iterations. +pub(crate) async fn shutdown_signal() { + use std::sync::OnceLock; + use tokio::sync::Notify; + + static NOTIFY: OnceLock> = OnceLock::new(); + + let notify = NOTIFY.get_or_init(|| { + let n = std::sync::Arc::new(Notify::new()); + let n2 = n.clone(); + tokio::spawn(async move { + #[cfg(unix)] + { + use tokio::signal::unix::{signal, SignalKind}; + match signal(SignalKind::terminate()) { + Ok(mut sigterm) => { + tokio::select! { + res = tokio::signal::ctrl_c() => { + res.expect("failed to listen for SIGINT"); + } + Some(_) = sigterm.recv() => {} + } + } + Err(e) => { + eprintln!( + "warning: could not register SIGTERM handler: {e}. \ + Listening for Ctrl+C only." + ); + tokio::signal::ctrl_c() + .await + .expect("failed to listen for SIGINT"); + } + } + } + #[cfg(not(unix))] + { + tokio::signal::ctrl_c() + .await + .expect("failed to listen for SIGINT"); + } + n2.notify_waiters(); + }); + n + }); + + notify.notified().await; +} + /// A trait for service-specific CLI helpers that inject custom commands. pub trait Helper: Send + Sync { /// Injects subcommands into the service command. diff --git a/src/helpers/modelarmor.rs b/crates/google-workspace-cli/src/helpers/modelarmor.rs similarity index 99% rename from src/helpers/modelarmor.rs rename to crates/google-workspace-cli/src/helpers/modelarmor.rs index 8ac9fc1c..e1551dd1 100644 --- a/src/helpers/modelarmor.rs +++ b/crates/google-workspace-cli/src/helpers/modelarmor.rs @@ -298,14 +298,14 @@ async fn model_armor_post(url: &str, body: &str) -> Result<(), GwsError> { let status = resp.status(); let text = resp.text().await.context("Failed to read response")?; - println!("{text}"); - if !status.is_success() { return Err(GwsError::Other(anyhow::anyhow!( - "API returned status {status}" + "API returned status {status}: {text}" ))); } + println!("{text}"); + Ok(()) } diff --git a/src/helpers/script.rs b/crates/google-workspace-cli/src/helpers/script.rs similarity index 98% rename from src/helpers/script.rs rename to crates/google-workspace-cli/src/helpers/script.rs index b0ad3497..11bcdebe 100644 --- a/src/helpers/script.rs +++ b/crates/google-workspace-cli/src/helpers/script.rs @@ -105,7 +105,8 @@ TIPS: let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect(); let (token, auth_method) = match auth::get_token(&scopes).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) => return Err(GwsError::Auth(format!("Script auth failed: {e}"))), }; let params = json!({ @@ -122,7 +123,6 @@ TIPS: auth_method, None, None, - None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), None, diff --git a/src/helpers/sheets.rs b/crates/google-workspace-cli/src/helpers/sheets.rs similarity index 86% rename from src/helpers/sheets.rs rename to crates/google-workspace-cli/src/helpers/sheets.rs index 76f36ab2..4357edec 100644 --- a/src/helpers/sheets.rs +++ b/crates/google-workspace-cli/src/helpers/sheets.rs @@ -51,14 +51,22 @@ impl Helper for SheetsHelper { .help("JSON array of rows, e.g. '[[\"a\",\"b\"],[\"c\",\"d\"]]'") .value_name("JSON"), ) + .arg( + Arg::new("range") + .long("range") + .help("Target range in A1 notation (e.g. 'Sheet2!A1'). Defaults to 'A1' (first sheet)") + .value_name("RANGE"), + ) .after_help( r#"EXAMPLES: gws sheets +append --spreadsheet ID --values 'Alice,100,true' gws sheets +append --spreadsheet ID --json-values '[["a","b"],["c","d"]]' + gws sheets +append --spreadsheet ID --range "Sheet2!A1" --values 'Alice,100' TIPS: Use --values for simple single-row appends. - Use --json-values for bulk multi-row inserts."#, + Use --json-values for bulk multi-row inserts. + Use --range to target a specific sheet tab (default: A1, i.e. first sheet)."#, ), ); @@ -108,7 +116,8 @@ TIPS: let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) => return Err(GwsError::Auth(format!("Sheets auth failed: {e}"))), }; let spreadsheets_res = doc.resources.get("spreadsheets").ok_or_else(|| { @@ -136,7 +145,6 @@ TIPS: auth_method, None, None, - None, matches.get_flag("dry-run"), &pagination, None, @@ -167,7 +175,8 @@ TIPS: let scope_strs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); let (token, auth_method) = match auth::get_token(&scope_strs).await { Ok(t) => (Some(t), executor::AuthMethod::OAuth), - Err(_) => (None, executor::AuthMethod::None), + Err(_) if matches.get_flag("dry-run") => (None, executor::AuthMethod::None), + Err(e) => return Err(GwsError::Auth(format!("Sheets auth failed: {e}"))), }; executor::execute_method( @@ -179,7 +188,6 @@ TIPS: auth_method, None, None, - None, matches.get_flag("dry-run"), &executor::PaginationConfig::default(), None, @@ -212,11 +220,9 @@ fn build_append_request( GwsError::Discovery("Method 'spreadsheets.values.append' not found".to_string()) })?; - let range = "A1"; - let params = json!({ "spreadsheetId": config.spreadsheet_id, - "range": range, + "range": config.range, "valueInputOption": "USER_ENTERED" }); @@ -262,6 +268,8 @@ fn build_read_request( pub struct AppendConfig { /// The ID of the spreadsheet to append to. pub spreadsheet_id: String, + /// Target range in A1 notation (e.g. "Sheet2!A1"). Defaults to "A1". + pub range: String, /// The rows to append, where each inner Vec represents one row. pub values: Vec>, } @@ -289,8 +297,14 @@ pub fn parse_append_args(matches: &ArgMatches) -> AppendConfig { Vec::new() }; + let range = matches + .get_one::("range") + .cloned() + .unwrap_or_else(|| "A1".to_string()); + AppendConfig { spreadsheet_id: matches.get_one::("spreadsheet").unwrap().clone(), + range, values, } } @@ -353,7 +367,8 @@ mod tests { let cmd = Command::new("test") .arg(Arg::new("spreadsheet").long("spreadsheet")) .arg(Arg::new("values").long("values")) - .arg(Arg::new("json-values").long("json-values")); + .arg(Arg::new("json-values").long("json-values")) + .arg(Arg::new("range").long("range")); cmd.try_get_matches_from(args).unwrap() } @@ -369,17 +384,32 @@ mod tests { let doc = make_mock_doc(); let config = AppendConfig { spreadsheet_id: "123".to_string(), + range: "A1".to_string(), values: vec![vec!["a".to_string(), "b".to_string(), "c".to_string()]], }; let (params, body, scopes) = build_append_request(&config, &doc).unwrap(); assert!(params.contains("123")); assert!(params.contains("USER_ENTERED")); + assert!(params.contains("A1")); assert!(body.contains("a")); assert!(body.contains("b")); assert_eq!(scopes[0], "https://scope"); } + #[test] + fn test_build_append_request_with_range() { + let doc = make_mock_doc(); + let config = AppendConfig { + spreadsheet_id: "123".to_string(), + range: "Sheet2!A1".to_string(), + values: vec![vec!["x".to_string()]], + }; + let (params, _body, _scopes) = build_append_request(&config, &doc).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(¶ms).unwrap(); + assert_eq!(parsed["range"], "Sheet2!A1"); + } + #[test] fn test_build_read_request() { let doc = make_mock_doc(); @@ -399,9 +429,32 @@ mod tests { let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a,b,c"]); let config = parse_append_args(&matches); assert_eq!(config.spreadsheet_id, "123"); + assert_eq!(config.range, "A1"); assert_eq!(config.values, vec![vec!["a", "b", "c"]]); } + #[test] + fn test_parse_append_args_with_range() { + let matches = make_matches_append(&[ + "test", + "--spreadsheet", + "123", + "--range", + "Sheet2!A1", + "--values", + "a,b", + ]); + let config = parse_append_args(&matches); + assert_eq!(config.range, "Sheet2!A1"); + } + + #[test] + fn test_parse_append_args_default_range() { + let matches = make_matches_append(&["test", "--spreadsheet", "123", "--values", "a"]); + let config = parse_append_args(&matches); + assert_eq!(config.range, "A1"); + } + #[test] fn test_parse_append_args_json_single_row() { let matches = make_matches_append(&[ @@ -436,6 +489,7 @@ mod tests { let doc = make_mock_doc(); let config = AppendConfig { spreadsheet_id: "123".to_string(), + range: "A1".to_string(), values: vec![ vec!["Alice".to_string(), "100".to_string()], vec!["Bob".to_string(), "200".to_string()], diff --git a/src/helpers/workflows.rs b/crates/google-workspace-cli/src/helpers/workflows.rs similarity index 97% rename from src/helpers/workflows.rs rename to crates/google-workspace-cli/src/helpers/workflows.rs index 48d21784..b921d864 100644 --- a/src/helpers/workflows.rs +++ b/crates/google-workspace-cli/src/helpers/workflows.rs @@ -18,6 +18,7 @@ use super::Helper; use crate::auth; use crate::error::GwsError; +use crate::output::sanitize_for_terminal; use clap::{Arg, ArgMatches, Command}; use serde_json::{json, Value}; use std::future::Future; @@ -297,9 +298,11 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { ], ) .await - .map_err(|e| { - eprintln!("Warning: Failed to fetch calendar events: {e}"); - e + .inspect_err(|e| { + eprintln!( + "Warning: Failed to fetch calendar events: {}", + sanitize_for_terminal(&e.to_string()) + ); }) .unwrap_or(json!({})); let events = events_json @@ -327,9 +330,11 @@ async fn handle_standup_report(matches: &ArgMatches) -> Result<(), GwsError> { &[("showCompleted", "false"), ("maxResults", "20")], ) .await - .map_err(|e| { - eprintln!("Warning: Failed to fetch tasks: {e}"); - e + .inspect_err(|e| { + eprintln!( + "Warning: Failed to fetch tasks: {}", + sanitize_for_terminal(&e.to_string()) + ); }) .unwrap_or(json!({})); let tasks = tasks_json @@ -557,9 +562,11 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { ], ) .await - .map_err(|e| { - eprintln!("Warning: Failed to fetch calendar events: {e}"); - e + .inspect_err(|e| { + eprintln!( + "Warning: Failed to fetch calendar events: {}", + sanitize_for_terminal(&e.to_string()) + ); }) .unwrap_or(json!({})); let events = events_json @@ -586,9 +593,11 @@ async fn handle_weekly_digest(matches: &ArgMatches) -> Result<(), GwsError> { &[("q", "is:unread"), ("maxResults", "1")], ) .await - .map_err(|e| { - eprintln!("Warning: Failed to fetch unread email count: {e}"); - e + .inspect_err(|e| { + eprintln!( + "Warning: Failed to fetch unread email count: {}", + sanitize_for_terminal(&e.to_string()) + ); }) .unwrap_or(json!({})); let unread_estimate = gmail_json diff --git a/src/logging.rs b/crates/google-workspace-cli/src/logging.rs similarity index 100% rename from src/logging.rs rename to crates/google-workspace-cli/src/logging.rs diff --git a/src/main.rs b/crates/google-workspace-cli/src/main.rs similarity index 96% rename from src/main.rs rename to crates/google-workspace-cli/src/main.rs index bd72c642..41dcc1e1 100644 --- a/src/main.rs +++ b/crates/google-workspace-cli/src/main.rs @@ -33,6 +33,7 @@ mod generate_skills; mod helpers; mod logging; mod oauth_config; +mod output; mod schema; mod services; mod setup; @@ -215,17 +216,40 @@ async fn run() -> Result<(), GwsError> { .ok() .flatten() .map(|s| s.as_str()); - let output_path = matched_args.get_one::("output").map(|s| s.as_str()); let upload_path = matched_args .try_get_one::("upload") .ok() .flatten() .map(|s| s.as_str()); - let upload_content_type = matched_args - .try_get_one::("upload-content-type") - .ok() - .flatten() - .map(|s| s.as_str()); + let output_path = matched_args.get_one::("output").map(|s| s.as_str()); + + // Validate file paths against traversal before any I/O. + // Use the returned canonical paths so the validated path is the one + // actually used for I/O (closes TOCTOU gap). + let upload_path_buf = if let Some(p) = upload_path { + Some(crate::validate::validate_safe_file_path(p, "--upload")?) + } else { + None + }; + let output_path_buf = if let Some(p) = output_path { + Some(crate::validate::validate_safe_file_path(p, "--output")?) + } else { + None + }; + let upload_path = upload_path_buf.as_deref().and_then(|p| p.to_str()); + let output_path = output_path_buf.as_deref().and_then(|p| p.to_str()); + + let upload = { + let upload_content_type = matched_args + .try_get_one::("upload-content-type") + .ok() + .flatten() + .map(|s| s.as_str()); + upload_path.map(|path| executor::UploadSource::File { + path, + content_type: upload_content_type, + }) + }; let dry_run = matched_args.get_flag("dry-run"); @@ -263,8 +287,7 @@ async fn run() -> Result<(), GwsError> { token.as_deref(), auth_method, output_path, - upload_path, - upload_content_type, + upload, dry_run, &pagination, sanitize_config.template.as_deref(), diff --git a/src/oauth_config.rs b/crates/google-workspace-cli/src/oauth_config.rs similarity index 97% rename from src/oauth_config.rs rename to crates/google-workspace-cli/src/oauth_config.rs index 02154b58..89f34535 100644 --- a/src/oauth_config.rs +++ b/crates/google-workspace-cli/src/oauth_config.rs @@ -84,13 +84,6 @@ pub fn save_client_config( crate::fs_util::atomic_write(&path, json.as_bytes()) .map_err(|e| anyhow::anyhow!("Failed to write client config: {e}"))?; - // Set file permissions to 600 on Unix (contains secrets) - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; - } - Ok(path) } diff --git a/crates/google-workspace-cli/src/output.rs b/crates/google-workspace-cli/src/output.rs new file mode 100644 index 00000000..7c539f0d --- /dev/null +++ b/crates/google-workspace-cli/src/output.rs @@ -0,0 +1,156 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Shared output helpers for terminal sanitization, coloring, and stderr +//! messaging. +//! +//! Every function that prints untrusted content to the terminal should use +//! these helpers to prevent escape-sequence injection, Unicode spoofing, +//! and to respect `NO_COLOR` / non-TTY environments. + +// Import dangerous-char detection from the library crate. +pub(crate) use google_workspace::validate::is_dangerous_unicode; + +// ── Sanitization ────────────────────────────────────────────────────── + +/// Strip dangerous characters from untrusted text before printing to the +/// terminal. Removes ASCII control characters (except `\n` and `\t`, +/// which are preserved for readability) and dangerous Unicode characters +/// (bidi overrides, zero-width chars, line/paragraph separators). +pub(crate) fn sanitize_for_terminal(text: &str) -> String { + text.chars() + .filter(|&c| { + if c == '\n' || c == '\t' { + return true; + } + if c.is_control() { + return false; + } + !is_dangerous_unicode(c) + }) + .collect() +} + +// ── Color ───────────────────────────────────────────────────────────── + +/// Returns true when stderr is connected to an interactive terminal and +/// `NO_COLOR` is not set, meaning ANSI color codes will be visible. +pub(crate) fn stderr_supports_color() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +/// Wrap `text` in ANSI bold + the given color code, resetting afterwards. +/// Returns the plain text unchanged when stderr is not a TTY or `NO_COLOR` +/// is set. +pub(crate) fn colorize(text: &str, ansi_color: &str) -> String { + if stderr_supports_color() && ansi_color.chars().all(|c| c.is_ascii_digit()) { + format!("\x1b[1;{ansi_color}m{text}\x1b[0m") + } else { + text.to_string() + } +} + +// ── Stderr helpers ──────────────────────────────────────────────────── + +/// Print a status message to stderr. The message is sanitized before +/// printing to prevent terminal injection. +#[allow(dead_code)] +pub(crate) fn status(msg: &str) { + eprintln!("{}", sanitize_for_terminal(msg)); +} + +/// Print a warning to stderr with a colored prefix. The message is +/// sanitized before printing. +#[allow(dead_code)] +pub(crate) fn warn(msg: &str) { + let prefix = colorize("warning:", "33"); // yellow + eprintln!("{prefix} {}", sanitize_for_terminal(msg)); +} + +/// Print an informational message to stderr. The message is sanitized +/// before printing. +#[allow(dead_code)] +pub(crate) fn info(msg: &str) { + eprintln!("{}", sanitize_for_terminal(msg)); +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── sanitize_for_terminal ───────────────────────────────────── + + #[test] + fn sanitize_strips_ansi_escape_sequences() { + let input = "normal \x1b[31mred text\x1b[0m end"; + let sanitized = sanitize_for_terminal(input); + assert_eq!(sanitized, "normal [31mred text[0m end"); + assert!(!sanitized.contains('\x1b')); + } + + #[test] + fn sanitize_preserves_newlines_and_tabs() { + let input = "line1\nline2\ttab"; + assert_eq!(sanitize_for_terminal(input), "line1\nline2\ttab"); + } + + #[test] + fn sanitize_strips_bell_and_backspace() { + let input = "hello\x07bell\x08backspace"; + assert_eq!(sanitize_for_terminal(input), "hellobellbackspace"); + } + + #[test] + fn sanitize_strips_carriage_return() { + let input = "real\rfake"; + assert_eq!(sanitize_for_terminal(input), "realfake"); + } + + #[test] + fn sanitize_strips_bidi_overrides() { + let input = "hello\u{202E}dlrow"; + assert_eq!(sanitize_for_terminal(input), "hellodlrow"); + } + + #[test] + fn sanitize_strips_zero_width_chars() { + assert_eq!(sanitize_for_terminal("foo\u{200B}bar"), "foobar"); + assert_eq!(sanitize_for_terminal("foo\u{FEFF}bar"), "foobar"); + } + + #[test] + fn sanitize_strips_line_separators() { + assert_eq!(sanitize_for_terminal("line1\u{2028}line2"), "line1line2"); + assert_eq!(sanitize_for_terminal("para1\u{2029}para2"), "para1para2"); + } + + #[test] + fn sanitize_strips_directional_isolates() { + assert_eq!(sanitize_for_terminal("a\u{2066}b\u{2069}c"), "abc"); + } + + #[test] + fn sanitize_preserves_normal_unicode() { + assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ"); + } + + // ── colorize ────────────────────────────────────────────────── + + #[test] + fn colorize_returns_text_in_no_color_mode() { + let result = colorize("hello", "31"); + assert!(result.contains("hello")); + } +} diff --git a/src/schema.rs b/crates/google-workspace-cli/src/schema.rs similarity index 100% rename from src/schema.rs rename to crates/google-workspace-cli/src/schema.rs diff --git a/crates/google-workspace-cli/src/services.rs b/crates/google-workspace-cli/src/services.rs new file mode 100644 index 00000000..6324929b --- /dev/null +++ b/crates/google-workspace-cli/src/services.rs @@ -0,0 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Service registry — re-exports from `google_workspace` library crate. + +pub use google_workspace::services::*; diff --git a/src/setup.rs b/crates/google-workspace-cli/src/setup.rs similarity index 96% rename from src/setup.rs rename to crates/google-workspace-cli/src/setup.rs index 10aebe1b..9ebd19cb 100644 --- a/src/setup.rs +++ b/crates/google-workspace-cli/src/setup.rs @@ -23,6 +23,7 @@ use std::process::Command; use serde_json::json; use crate::error::GwsError; +use crate::output::sanitize_for_terminal; use crate::setup_tui::{PickerResult, SelectItem, SetupWizard, StepStatus}; @@ -376,33 +377,52 @@ pub struct SetupOptions { pub login: bool, } -/// Parse setup flags from args. -pub fn parse_setup_args(args: &[String]) -> SetupOptions { - let mut project = None; - let mut dry_run = false; - let mut login = false; - let mut i = 0; - while i < args.len() { - if args[i] == "--project" && i + 1 < args.len() { - project = Some(args[i + 1].clone()); - i += 2; - } else if args[i].starts_with("--project=") { - project = Some(args[i].split_once('=').unwrap().1.to_string()); - i += 1; - } else if args[i] == "--dry-run" { - dry_run = true; - i += 1; - } else if args[i] == "--login" { - login = true; - i += 1; - } else { - i += 1; +/// Build the clap Command for `gws auth setup`. +fn setup_command() -> clap::Command { + clap::Command::new("setup") + .about("Configure GCP project + OAuth client (requires gcloud)") + .arg( + clap::Arg::new("project") + .long("project") + .help("Use a specific GCP project") + .value_name("id"), + ) + .arg( + clap::Arg::new("login") + .long("login") + .help("Run `gws auth login` after successful setup") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("dry-run") + .long("dry-run") + .help("Preview changes without making them") + .action(clap::ArgAction::SetTrue), + ) +} + +/// Parse setup flags from args using clap. +/// Returns `Ok(Some(opts))` on success, `Ok(None)` if clap handled +/// `--help`/`--version` (already printed), or `Err` for invalid args. +pub fn parse_setup_args(args: &[String]) -> Result, GwsError> { + match setup_command() + .try_get_matches_from(std::iter::once("setup".to_string()).chain(args.iter().cloned())) + { + Ok(matches) => Ok(Some(SetupOptions { + project: matches.get_one::("project").cloned(), + dry_run: matches.get_flag("dry-run"), + login: matches.get_flag("login"), + })), + Err(e) + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() == clap::error::ErrorKind::DisplayVersion => + { + e.print().map_err(|io_err| { + GwsError::Validation(format!("Failed to print help: {io_err}")) + })?; + Ok(None) } - } - SetupOptions { - project, - dry_run, - login, + Err(e) => Err(GwsError::Validation(e.to_string())), } } @@ -1371,7 +1391,7 @@ async fn stage_enable_apis(ctx: &mut SetupContext) -> Result Result { /// Run the full setup flow. Orchestrates all steps and outputs JSON summary. pub async fn run_setup(args: &[String]) -> Result<(), GwsError> { - let opts = parse_setup_args(args); + // parse_setup_args uses clap, which handles --help / -h automatically. + let opts = match parse_setup_args(args)? { + Some(opts) => opts, + None => return Ok(()), // --help was printed, exit cleanly + }; let dry_run = opts.dry_run; let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin()) && !dry_run; @@ -1825,7 +1849,7 @@ mod tests { #[test] fn test_parse_setup_args_empty() { - let opts = parse_setup_args(&[]); + let opts = parse_setup_args(&[]).unwrap().unwrap(); assert!(opts.project.is_none()); assert!(!opts.dry_run); assert!(!opts.login); @@ -1834,7 +1858,7 @@ mod tests { #[test] fn test_parse_setup_args_with_project() { let args = vec!["--project".into(), "my-project".into()]; - let opts = parse_setup_args(&args); + let opts = parse_setup_args(&args).unwrap().unwrap(); assert_eq!(opts.project.as_deref(), Some("my-project")); assert!(!opts.login); } @@ -1842,23 +1866,28 @@ mod tests { #[test] fn test_parse_setup_args_with_project_equals() { let args = vec!["--project=my-project".into()]; - let opts = parse_setup_args(&args); + let opts = parse_setup_args(&args).unwrap().unwrap(); assert_eq!(opts.project.as_deref(), Some("my-project")); assert!(!opts.login); } #[test] - fn test_parse_setup_args_ignores_unknown() { - let args = vec!["--verbose".into(), "--unknown".into()]; - let opts = parse_setup_args(&args); - assert!(opts.project.is_none()); - assert!(!opts.login); + fn test_parse_setup_args_rejects_unknown() { + let args = vec!["--verbose".into()]; + assert!(parse_setup_args(&args).is_err()); + } + + #[test] + fn test_parse_setup_args_help_returns_none() { + let args = vec!["--help".into()]; + // --help triggers display and returns Ok(None) for clean exit + assert!(parse_setup_args(&args).unwrap().is_none()); } #[test] fn test_parse_setup_args_dry_run() { let args = vec!["--dry-run".into()]; - let opts = parse_setup_args(&args); + let opts = parse_setup_args(&args).unwrap().unwrap(); assert!(opts.dry_run); assert!(!opts.login); } @@ -1866,7 +1895,7 @@ mod tests { #[test] fn test_parse_setup_args_dry_run_with_project() { let args: Vec = vec!["--dry-run".into(), "--project".into(), "p".into()]; - let opts = parse_setup_args(&args); + let opts = parse_setup_args(&args).unwrap().unwrap(); assert!(opts.dry_run); assert_eq!(opts.project.as_deref(), Some("p")); assert!(!opts.login); @@ -1875,7 +1904,7 @@ mod tests { #[test] fn test_parse_setup_args_login_flag() { let args: Vec = vec!["--login".into()]; - let opts = parse_setup_args(&args); + let opts = parse_setup_args(&args).unwrap().unwrap(); assert!(opts.login); assert!(!opts.dry_run); assert!(opts.project.is_none()); diff --git a/src/setup_tui.rs b/crates/google-workspace-cli/src/setup_tui.rs similarity index 100% rename from src/setup_tui.rs rename to crates/google-workspace-cli/src/setup_tui.rs diff --git a/src/text.rs b/crates/google-workspace-cli/src/text.rs similarity index 100% rename from src/text.rs rename to crates/google-workspace-cli/src/text.rs diff --git a/src/timezone.rs b/crates/google-workspace-cli/src/timezone.rs similarity index 100% rename from src/timezone.rs rename to crates/google-workspace-cli/src/timezone.rs diff --git a/src/token_storage.rs b/crates/google-workspace-cli/src/token_storage.rs similarity index 80% rename from src/token_storage.rs rename to crates/google-workspace-cli/src/token_storage.rs index aa1726c7..5fb17a00 100644 --- a/src/token_storage.rs +++ b/crates/google-workspace-cli/src/token_storage.rs @@ -18,6 +18,8 @@ use std::sync::Arc; use tokio::sync::Mutex; use yup_oauth2::storage::{TokenInfo, TokenStorage, TokenStorageError}; +use crate::output::sanitize_for_terminal; + /// A custom token storage implementation for `yup-oauth2` that encrypts /// the cached tokens at rest using AES-256-GCM encryption. pub struct EncryptedTokenStorage { @@ -55,7 +57,10 @@ impl EncryptedTokenStorage { let json = match String::from_utf8(decrypted) { Ok(j) => j, Err(e) => { - eprintln!("warning: token cache contains invalid UTF-8: {e}"); + eprintln!( + "warning: token cache contains invalid UTF-8: {}", + sanitize_for_terminal(&e.to_string()) + ); return HashMap::new(); } }; @@ -63,7 +68,10 @@ impl EncryptedTokenStorage { match serde_json::from_str(&json) { Ok(map) => map, Err(e) => { - eprintln!("warning: failed to parse token cache JSON: {e}"); + eprintln!( + "warning: failed to parse token cache JSON: {}", + sanitize_for_terminal(&e.to_string()) + ); HashMap::new() } } @@ -74,11 +82,25 @@ impl EncryptedTokenStorage { let encrypted = crate::credential_store::encrypt(json.as_bytes())?; if let Some(parent) = self.file_path.parent() { - let _ = tokio::fs::create_dir_all(parent).await; + tokio::fs::create_dir_all(parent).await.map_err(|e| { + anyhow::anyhow!( + "Failed to create token directory '{}': {}", + sanitize_for_terminal(&parent.display().to_string()), + e + ) + })?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); + tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) + .await + .map_err(|e| { + anyhow::anyhow!( + "Failed to set permissions on token directory '{}': {}", + sanitize_for_terminal(&parent.display().to_string()), + e + ) + })?; } } diff --git a/crates/google-workspace-cli/src/validate.rs b/crates/google-workspace-cli/src/validate.rs new file mode 100644 index 00000000..7b41d0f9 --- /dev/null +++ b/crates/google-workspace-cli/src/validate.rs @@ -0,0 +1,17 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Input validation — re-exports from `google_workspace` library crate. + +pub use google_workspace::validate::*; diff --git a/templates/modelarmor/jailbreak.json b/crates/google-workspace-cli/templates/modelarmor/jailbreak.json similarity index 100% rename from templates/modelarmor/jailbreak.json rename to crates/google-workspace-cli/templates/modelarmor/jailbreak.json diff --git a/crates/google-workspace/Cargo.toml b/crates/google-workspace/Cargo.toml new file mode 100644 index 00000000..3dc9f083 --- /dev/null +++ b/crates/google-workspace/Cargo.toml @@ -0,0 +1,40 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "google-workspace" +version = "0.22.5" +edition = "2021" +description = "Google Workspace API client — Discovery Document types, service registry, and HTTP utilities" +license = "Apache-2.0" +repository = "https://github.com/googleworkspace/cli" +homepage = "https://github.com/googleworkspace/cli" +readme = "README.md" +authors = ["Justin Poehnelt"] +keywords = ["google-workspace", "google", "discovery", "api-client"] +categories = ["api-bindings", "web-programming"] + +[dependencies] +anyhow = "1" +percent-encoding = "2.3.2" +reqwest = { version = "0.12", features = ["json", "rustls-tls-native-roots"], default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["time", "fs"] } +tracing = "0.1" + +[dev-dependencies] +serial_test = "3.4.0" +tempfile = "3" diff --git a/crates/google-workspace/README.md b/crates/google-workspace/README.md new file mode 100644 index 00000000..a10b832c --- /dev/null +++ b/crates/google-workspace/README.md @@ -0,0 +1,40 @@ +# google-workspace + +Core Rust library for interacting with Google Workspace APIs via the [Discovery Service](https://developers.google.com/discovery). + +This crate provides the foundational types and utilities used by the [`google-workspace-cli`](https://crates.io/crates/google-workspace-cli) (`gws`) command-line tool, and can be used independently for programmatic access. + +> **Dynamic Discovery** — this library fetches Google's Discovery Documents at runtime rather than relying on generated client crates. When Google adds or updates an API endpoint, your code picks it up automatically. + +## Modules + +| Module | Description | +|---|---| +| `discovery` | Discovery Document types (`RestDescription`, `RestMethod`, etc.) and async fetch with optional disk caching | +| `services` | Service registry mapping aliases (e.g., `drive`) to API name/version pairs | +| `error` | Structured `GwsError` enum with exit codes and JSON serialization | +| `validate` | Input validation: path safety, resource name checks, URL encoding | +| `client` | HTTP client builder with automatic retry logic | + +## Usage + +```rust +use google_workspace::discovery::fetch_discovery_document; +use google_workspace::services::resolve_service; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let (api, version) = resolve_service("drive").unwrap(); + let doc = fetch_discovery_document(api, version, None).await?; + + println!("{} {} — {} resources", + doc.name, doc.version, + doc.resources.len(), + ); + Ok(()) +} +``` + +## License + +Apache-2.0 — see [LICENSE](https://github.com/googleworkspace/cli/blob/main/LICENSE). diff --git a/crates/google-workspace/src/client.rs b/crates/google-workspace/src/client.rs new file mode 100644 index 00000000..4650cfe1 --- /dev/null +++ b/crates/google-workspace/src/client.rs @@ -0,0 +1,164 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! HTTP client with retry logic for Google API requests. + +use std::sync::OnceLock; + +use reqwest::header::{HeaderMap, HeaderValue}; + +const MAX_RETRIES: u32 = 3; +/// Maximum seconds to sleep on a 429 Retry-After header. Prevents a hostile +/// or misconfigured server from hanging the process indefinitely. +const MAX_RETRY_DELAY_SECS: u64 = 60; +const CONNECT_TIMEOUT_SECS: u64 = 10; + +fn build_client_inner() -> Result { + let mut headers = HeaderMap::new(); + let name = env!("CARGO_PKG_NAME"); + let version = env!("CARGO_PKG_VERSION"); + + // Format: gl-rust/name-version (the gl-rust/ prefix is fixed) + let client_header = format!("gl-rust/{}-{}", name, version); + if let Ok(header_value) = HeaderValue::from_str(&client_header) { + headers.insert("x-goog-api-client", header_value); + } + + reqwest::Client::builder() + .default_headers(headers) + .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}")) +} + +pub fn build_client() -> Result { + build_client_inner().map_err(|message| crate::error::GwsError::Other(anyhow::anyhow!(message))) +} + +/// Returns a shared reqwest client clone backed by a single global connection pool. +/// +/// `reqwest::Client` is cheap to clone, so callers can take ownership of the +/// returned value while still sharing pooled connections underneath. +pub fn shared_client() -> Result { + static CLIENT: OnceLock> = OnceLock::new(); + + match CLIENT.get_or_init(build_client_inner) { + Ok(client) => Ok(client.clone()), + Err(message) => Err(crate::error::GwsError::Other(anyhow::anyhow!( + message.clone() + ))), + } +} + +/// Send an HTTP request with automatic retry on 429 (rate limit) responses +/// and transient connection/timeout errors. +/// Respects the `Retry-After` header; falls back to exponential backoff (1s, 2s, 4s). +pub async fn send_with_retry( + build_request: impl Fn() -> reqwest::RequestBuilder, +) -> Result { + let mut last_err: Option = None; + + for attempt in 0..MAX_RETRIES { + match build_request().send().await { + Ok(resp) => { + if resp.status() != reqwest::StatusCode::TOO_MANY_REQUESTS { + return Ok(resp); + } + + let header_value = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()); + let retry_after = compute_retry_delay(header_value, attempt); + tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await; + } + Err(e) if e.is_connect() || e.is_timeout() => { + // Transient network error — retry with exponential backoff + let delay = compute_retry_delay(None, attempt); + tokio::time::sleep(std::time::Duration::from_secs(delay)).await; + last_err = Some(e); + } + Err(e) => return Err(e), + } + } + + // Final attempt — return whatever we get + match build_request().send().await { + Ok(resp) => Ok(resp), + Err(e) => Err(last_err.unwrap_or(e)), + } +} + +/// Compute the retry delay from a Retry-After header value and attempt number. +/// Falls back to exponential backoff (1, 2, 4s) when the header is absent or +/// unparseable. Always caps the result at MAX_RETRY_DELAY_SECS. +fn compute_retry_delay(header_value: Option<&str>, attempt: u32) -> u64 { + header_value + .and_then(|s| s.parse::().ok()) + .unwrap_or(2u64.saturating_pow(attempt)) + .min(MAX_RETRY_DELAY_SECS) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_client_succeeds() { + assert!(build_client().is_ok()); + } + + #[test] + fn shared_client_succeeds() { + assert!(shared_client().is_ok()); + } + + #[test] + fn shared_client_can_be_reused() { + let client_a = shared_client().unwrap(); + let client_b = shared_client().unwrap(); + let request_a = client_a.get("https://example.com").build().unwrap(); + let request_b = client_b.get("https://example.com").build().unwrap(); + assert_eq!(request_a.url(), request_b.url()); + } + + #[test] + fn retry_delay_caps_large_header_value() { + assert_eq!(compute_retry_delay(Some("999999"), 0), MAX_RETRY_DELAY_SECS); + } + + #[test] + fn retry_delay_passes_through_small_header_value() { + assert_eq!(compute_retry_delay(Some("5"), 0), 5); + } + + #[test] + fn retry_delay_falls_back_to_exponential_on_missing_header() { + assert_eq!(compute_retry_delay(None, 0), 1); // 2^0 + assert_eq!(compute_retry_delay(None, 1), 2); // 2^1 + assert_eq!(compute_retry_delay(None, 2), 4); // 2^2 + } + + #[test] + fn retry_delay_falls_back_on_unparseable_header() { + assert_eq!(compute_retry_delay(Some("not-a-number"), 1), 2); + assert_eq!(compute_retry_delay(Some(""), 0), 1); + } + + #[test] + fn retry_delay_caps_at_boundary() { + assert_eq!(compute_retry_delay(Some("60"), 0), 60); + assert_eq!(compute_retry_delay(Some("61"), 0), MAX_RETRY_DELAY_SECS); + } +} diff --git a/src/discovery.rs b/crates/google-workspace/src/discovery.rs similarity index 93% rename from src/discovery.rs rename to crates/google-workspace/src/discovery.rs index 2af4a669..39c3867d 100644 --- a/src/discovery.rs +++ b/crates/google-workspace/src/discovery.rs @@ -184,9 +184,13 @@ pub struct JsonSchemaProperty { } /// Fetches and caches a Google Discovery Document. +/// +/// When `cache_dir` is `Some`, the document is cached on disk with a 24-hour +/// TTL. Pass `None` to skip caching entirely. pub async fn fetch_discovery_document( service: &str, version: &str, + cache_dir: Option<&std::path::path>, ) -> anyhow::Result { // Validate service and version to prevent path traversal in cache filenames // and injection in discovery URLs. @@ -195,17 +199,15 @@ pub async fn fetch_discovery_document( let version = crate::validate::validate_api_identifier(version).map_err(|e| anyhow::anyhow!("{e}"))?; - let cache_dir = crate::auth_commands::config_dir().join("cache"); - std::fs::create_dir_all(&cache_dir)?; - - let cache_file = cache_dir.join(format!("{service}_{version}.json")); - // Check cache (24hr TTL) - if cache_file.exists() { - if let Ok(metadata) = std::fs::metadata(&cache_file) { + if let Some(dir) = cache_dir { + tokio::fs::create_dir_all(dir).await?; + let cache_file = dir.join(format!("{service}_{version}.json")); + + if let Ok(metadata) = tokio::fs::metadata(&cache_file).await { if let Ok(modified) = metadata.modified() { if modified.elapsed().unwrap_or_default() < std::time::Duration::from_secs(86400) { - let data = std::fs::read_to_string(&cache_file)?; + let data = tokio::fs::read_to_string(&cache_file).await?; let doc: RestDescription = serde_json::from_str(&data)?; tracing::debug!(service = %service, version = %version, "Discovery cache hit"); return Ok(doc); @@ -244,9 +246,11 @@ pub async fn fetch_discovery_document( }; // Write to cache - if let Err(e) = std::fs::write(&cache_file, &body) { - // Non-fatal: just warn via stderr-safe approach - let _ = e; + if let Some(dir) = cache_dir { + let cache_file = dir.join(format!("{service}_{version}.json")); + if let Err(e) = tokio::fs::write(&cache_file, &body).await { + tracing::warn!(error = %e, "Failed to write discovery cache"); + } } let doc: RestDescription = serde_json::from_str(&body)?; diff --git a/src/error.rs b/crates/google-workspace/src/error.rs similarity index 73% rename from src/error.rs rename to crates/google-workspace/src/error.rs index bb7c086e..71ae0390 100644 --- a/src/error.rs +++ b/crates/google-workspace/src/error.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Structured error types for Google Workspace API operations. + use serde_json::json; use thiserror::Error; @@ -39,31 +41,6 @@ pub enum GwsError { Other(#[from] anyhow::Error), } -/// Human-readable exit code table, keyed by (code, description). -/// -/// Used by `print_usage()` so the help text stays in sync with the -/// constants defined below without requiring manual updates in two places. -pub const EXIT_CODE_DOCUMENTATION: &[(i32, &str)] = &[ - (0, "Success"), - ( - GwsError::EXIT_CODE_API, - "API error — Google returned an error response", - ), - ( - GwsError::EXIT_CODE_AUTH, - "Auth error — credentials missing or invalid", - ), - ( - GwsError::EXIT_CODE_VALIDATION, - "Validation — bad arguments or input", - ), - ( - GwsError::EXIT_CODE_DISCOVERY, - "Discovery — could not fetch API schema", - ), - (GwsError::EXIT_CODE_OTHER, "Internal — unexpected failure"), -]; - impl GwsError { /// Exit code for [`GwsError::Api`] variants. pub const EXIT_CODE_API: i32 = 1; @@ -77,15 +54,6 @@ impl GwsError { pub const EXIT_CODE_OTHER: i32 = 5; /// Map each error variant to a stable, documented exit code. - /// - /// | Code | Meaning | - /// |------|----------------------------------------------| - /// | 0 | Success (never returned here) | - /// | 1 | API error — Google returned an error response | - /// | 2 | Auth error — credentials missing or invalid | - /// | 3 | Validation error — bad arguments or input | - /// | 4 | Discovery error — could not fetch API schema | - /// | 5 | Internal error — unexpected failure | pub fn exit_code(&self) -> i32 { match self { GwsError::Api { .. } => Self::EXIT_CODE_API, @@ -109,8 +77,6 @@ impl GwsError { "message": message, "reason": reason, }); - // Include enable_url in JSON output when present (accessNotConfigured errors). - // This preserves machine-readable compatibility while adding new optional field. if let Some(url) = enable_url { error_obj["enable_url"] = json!(url); } @@ -148,36 +114,6 @@ impl GwsError { } } -/// Formats any error as a JSON object and prints to stdout. -/// -/// For `accessNotConfigured` errors (HTTP 403, reason `accessNotConfigured`), -/// additional human-readable guidance is printed to stderr explaining how to -/// enable the API in GCP. The JSON output on stdout is unchanged (machine-readable). -pub fn print_error_json(err: &GwsError) { - let json = err.to_json(); - println!( - "{}", - serde_json::to_string_pretty(&json).unwrap_or_default() - ); - - // Print actionable guidance to stderr for accessNotConfigured errors - if let GwsError::Api { - reason, enable_url, .. - } = err - { - if reason == "accessNotConfigured" { - eprintln!(); - eprintln!("💡 API not enabled for your GCP project."); - if let Some(url) = enable_url { - eprintln!(" Enable it at: {url}"); - } else { - eprintln!(" Visit the GCP Console → APIs & Services → Library to enable the required API."); - } - eprintln!(" After enabling, wait a few seconds and retry your command."); - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -227,7 +163,6 @@ mod tests { #[test] fn test_exit_codes_are_distinct() { - // Ensure all named constants are unique (regression guard). let codes = [ GwsError::EXIT_CODE_API, GwsError::EXIT_CODE_AUTH, @@ -294,13 +229,11 @@ mod tests { assert_eq!(json["error"]["reason"], "internalError"); } - // --- accessNotConfigured tests --- - #[test] fn test_error_to_json_access_not_configured_with_url() { let err = GwsError::Api { code: 403, - message: "Gmail API has not been used in project 549352339482 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482 then retry.".to_string(), + message: "Gmail API has not been used in project 549352339482 before or it is disabled.".to_string(), reason: "accessNotConfigured".to_string(), enable_url: Some("https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=549352339482".to_string()), }; @@ -324,7 +257,6 @@ mod tests { let json = err.to_json(); assert_eq!(json["error"]["code"], 403); assert_eq!(json["error"]["reason"], "accessNotConfigured"); - // enable_url key should not appear in JSON when None assert!(json["error"]["enable_url"].is_null()); } } diff --git a/crates/google-workspace/src/lib.rs b/crates/google-workspace/src/lib.rs new file mode 100644 index 00000000..b97d567b --- /dev/null +++ b/crates/google-workspace/src/lib.rs @@ -0,0 +1,32 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Google Workspace API client library. +//! +//! Provides types and utilities for working with Google Workspace APIs +//! via the [Discovery Service](https://developers.google.com/discovery). +//! +//! # Modules +//! +//! - [`discovery`] — Discovery Document types and fetching +//! - [`error`] — Structured error types +//! - [`services`] — Service name registry and resolution +//! - [`validate`] — Input validation and URL encoding utilities +//! - [`client`] — HTTP client with retry logic + +pub mod client; +pub mod discovery; +pub mod error; +pub mod services; +pub mod validate; diff --git a/src/services.rs b/crates/google-workspace/src/services.rs similarity index 94% rename from src/services.rs rename to crates/google-workspace/src/services.rs index 40a4b816..72c96329 100644 --- a/src/services.rs +++ b/crates/google-workspace/src/services.rs @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Google Workspace service registry. +//! +//! Maps service aliases (e.g. "drive", "gmail") to Discovery API names and versions. + use crate::error::GwsError; /// A known service with its alias, API name, version, and description. @@ -126,6 +130,12 @@ pub const SERVICES: &[ServiceEntry] = &[ version: "v1", description: "Cross-service productivity workflows", }, + ServiceEntry { + aliases: &["script"], + api_name: "script", + version: "v1", + description: "Manage Google Apps Script projects", + }, ]; /// Resolves a service alias to (api_name, version). diff --git a/src/validate.rs b/crates/google-workspace/src/validate.rs similarity index 61% rename from src/validate.rs rename to crates/google-workspace/src/validate.rs index cfefd603..32ef200f 100644 --- a/src/validate.rs +++ b/crates/google-workspace/src/validate.rs @@ -14,13 +14,54 @@ //! Shared input validation helpers. //! -//! These functions harden CLI inputs against adversarial or accidentally +//! These functions harden inputs against adversarial or accidentally //! malformed values — especially important when the CLI is invoked by an //! LLM agent rather than a human operator. use crate::error::GwsError; use std::path::{Path, PathBuf}; +// ── Dangerous character detection ───────────────────────────────────── + +/// Returns `true` for Unicode characters that are dangerous in terminal +/// output but not caught by `char::is_control()`: zero-width chars, bidi +/// overrides, Unicode line/paragraph separators, and directional isolates. +pub fn is_dangerous_unicode(c: char) -> bool { + matches!(c, + // zero-width: ZWSP, ZWNJ, ZWJ, BOM/ZWNBSP + '\u{200B}'..='\u{200D}' | '\u{FEFF}' | + // bidi: LRE, RLE, PDF, LRO, RLO + '\u{202A}'..='\u{202E}' | + // line / paragraph separators + '\u{2028}'..='\u{2029}' | + // directional isolates: LRI, RLI, FSI, PDI + '\u{2066}'..='\u{2069}' + ) +} + +/// Rejects strings containing control characters (C0: U+0000–U+001F, +/// C1: U+0080–U+009F, and DEL: U+007F) or dangerous Unicode characters +/// such as zero-width chars, bidi overrides, and line/paragraph separators. +/// +/// Used for validating argument values at the parse boundary. +pub fn reject_dangerous_chars(value: &str, flag_name: &str) -> Result<(), GwsError> { + for c in value.chars() { + if c.is_control() { + return Err(GwsError::Validation(format!( + "{flag_name} contains invalid control characters" + ))); + } + if is_dangerous_unicode(c) { + return Err(GwsError::Validation(format!( + "{flag_name} contains invalid Unicode characters" + ))); + } + } + Ok(()) +} + +// ── Path validators ─────────────────────────────────────────────────── + /// Validates that `dir` is a safe output directory. /// /// The path is resolved relative to CWD. The function rejects paths that @@ -29,7 +70,7 @@ use std::path::{Path, PathBuf}; /// /// Returns the canonicalized path on success. pub fn validate_safe_output_dir(dir: &str) -> Result { - reject_control_chars(dir, "--output-dir")?; + reject_dangerous_chars(dir, "--output-dir")?; let path = Path::new(dir); @@ -77,7 +118,7 @@ pub fn validate_safe_output_dir(dir: &str) -> Result { /// Similar to [`validate_safe_output_dir`] but also follows symlinks /// safely and ensures the resolved path stays under CWD. pub fn validate_safe_dir_path(dir: &str) -> Result { - reject_control_chars(dir, "--dir")?; + reject_dangerous_chars(dir, "--dir")?; let path = Path::new(dir); @@ -118,15 +159,77 @@ pub fn validate_safe_dir_path(dir: &str) -> Result { Ok(canonical) } -/// Rejects strings containing null bytes or ASCII control characters -/// (including DEL, 0x7F). -fn reject_control_chars(value: &str, flag_name: &str) -> Result<(), GwsError> { - if value.bytes().any(|b| b < 0x20 || b == 0x7F) { +/// Validates that a file path (e.g. `--upload` or `--output`) is safe. +/// +/// Rejects paths that escape above CWD via `..` traversal, contain +/// control characters, or follow symlinks to locations outside CWD. +/// Absolute paths are allowed (reading an existing file from a known +/// location is legitimate) but the resolved target must still live +/// under CWD. +/// +/// # TOCTOU caveat +/// +/// This is a best-effort defence-in-depth check. A local attacker with +/// write access to a parent directory could replace a path component +/// between this validation and the subsequent I/O. Fully eliminating +/// TOCTOU would require `openat(O_NOFOLLOW)` on each path component, +/// which is tracked as a follow-up for Unix platforms. +pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result { + reject_dangerous_chars(path_str, flag_name)?; + + let path = Path::new(path_str); + let cwd = std::env::current_dir() + .map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))?; + + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + + // For existing files, canonicalize to resolve symlinks. + // For non-existing files, get the prefix canonicalized then normalize + // the remaining components to resolve any `..` or `.` segments. + let canonical = if resolved.exists() { + resolved.canonicalize().map_err(|e| { + GwsError::Validation(format!("Failed to resolve {flag_name} '{}': {e}", path_str)) + })? + } else { + let raw = normalize_non_existing(&resolved)?; + // normalize_non_existing does NOT resolve `..` in the non-existent + // suffix. We must resolve them here to prevent bypass via paths like + // `non_existent/../../etc/passwd`. + normalize_dotdot(&raw) + }; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + GwsError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { return Err(GwsError::Validation(format!( - "{flag_name} contains invalid control characters" + "{flag_name} '{}' resolves to '{}' which is outside the current directory", + path_str, + canonical.display() ))); } - Ok(()) + + Ok(canonical) +} + +/// Resolve `.` and `..` components in a path without touching the filesystem. +fn normalize_dotdot(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + std::path::Component::ParentDir => { + out.pop(); + } + std::path::Component::CurDir => {} + c => out.push(c), + } + } + out } /// Resolves a path that may not exist yet by canonicalizing the existing @@ -167,6 +270,8 @@ fn normalize_non_existing(path: &Path) -> Result { Ok(resolved) } +// ── URL encoding ────────────────────────────────────────────────────── + /// Percent-encode a value for use as a single URL path segment (e.g., file ID, /// calendar ID, message ID). All non-alphanumeric characters are encoded. pub fn encode_path_segment(s: &str) -> String { @@ -187,6 +292,8 @@ pub fn encode_path_preserving_slashes(s: &str) -> String { .join("/") } +// ── Resource / API validators ───────────────────────────────────────── + /// Validate a multi-segment resource name (e.g., `spaces/ABC`, `subscriptions/123`). /// Rejects path traversal, control characters, and URL-special characters including `%` /// to prevent URL-encoded bypasses. Returns the validated name or an error. @@ -201,7 +308,9 @@ pub fn validate_resource_name(s: &str) -> Result<&str, GwsError> { "Resource name must not contain path traversal ('..') segments: {s}" ))); } - if s.contains('0円') || s.chars().any(|c| c.is_control()) { + if s.chars() + .any(|c| c == '0円' || c.is_control() || is_dangerous_unicode(c)) + { return Err(GwsError::Validation(format!( "Resource name contains invalid characters: {s}" ))); @@ -253,9 +362,7 @@ mod tests { #[test] #[serial] fn test_output_dir_relative_subdir() { - // Create a real temp dir and change into it for the test let dir = tempdir().unwrap(); - // Canonicalize to handle macOS /var -> /private/var symlink let canonical_dir = dir.path().canonicalize().unwrap(); let sub = canonical_dir.join("output"); fs::create_dir_all(&sub).unwrap(); @@ -275,21 +382,18 @@ mod tests { let dir = tempdir().unwrap(); let canonical_dir = dir.path().canonicalize().unwrap(); - // Create a directory inside the tempdir let allowed_dir = canonical_dir.join("allowed"); fs::create_dir(&allowed_dir).unwrap(); - // Create a symlink pointing OUTSIDE the tempdir (e.g. to /tmp) let symlink_path = canonical_dir.join("sneaky_link"); #[cfg(unix)] std::os::unix::fs::symlink("/tmp", &symlink_path).unwrap(); #[cfg(windows)] - return; // Skip on Windows due to privilege requirements for symlinks + return; let saved_cwd = std::env::current_dir().unwrap(); std::env::set_current_dir(&canonical_dir).unwrap(); - // Try to validate the symlink resolving outside CWD let result = validate_safe_output_dir("sneaky_link"); std::env::set_current_dir(&saved_cwd).unwrap(); @@ -372,26 +476,26 @@ mod tests { assert!(validate_safe_dir_path("/usr/local").is_err()); } - // --- reject_control_chars --- + // --- reject_dangerous_chars --- #[test] - fn test_reject_control_chars_clean() { - assert!(reject_control_chars("hello/world", "test").is_ok()); + fn test_reject_dangerous_chars_clean() { + assert!(reject_dangerous_chars("hello/world", "test").is_ok()); } #[test] - fn test_reject_control_chars_tab() { - assert!(reject_control_chars("hello\tworld", "test").is_err()); + fn test_reject_dangerous_chars_tab() { + assert!(reject_dangerous_chars("hello\tworld", "test").is_err()); } #[test] - fn test_reject_control_chars_newline() { - assert!(reject_control_chars("hello\nworld", "test").is_err()); + fn test_reject_dangerous_chars_newline() { + assert!(reject_dangerous_chars("hello\nworld", "test").is_err()); } #[test] - fn test_reject_control_chars_del() { - assert!(reject_control_chars("hello\x7Fworld", "test").is_err()); + fn test_reject_dangerous_chars_del() { + assert!(reject_dangerous_chars("hello\x7Fworld", "test").is_err()); } // -- encode_path_segment -------------------------------------------------- @@ -403,7 +507,6 @@ mod tests { #[test] fn test_encode_path_segment_email() { - // Calendar IDs are often email addresses let encoded = encode_path_segment("user@gmail.com"); assert!(!encoded.contains('@')); assert!(!encoded.contains('.')); @@ -411,7 +514,6 @@ mod tests { #[test] fn test_encode_path_segment_query_injection() { - // LLM might include query params in an ID by mistake let encoded = encode_path_segment("fileid?fields=name"); assert!(!encoded.contains('?')); assert!(!encoded.contains('=')); @@ -425,7 +527,6 @@ mod tests { #[test] fn test_encode_path_segment_path_traversal() { - // Encoding makes traversal segments harmless let encoded = encode_path_segment("../../etc/passwd"); assert!(!encoded.contains('/')); assert!(!encoded.contains("..")); @@ -433,7 +534,6 @@ mod tests { #[test] fn test_encode_path_segment_unicode() { - // LLM might pass unicode characters let encoded = encode_path_segment("日本語ID"); assert!(!encoded.contains('日')); } @@ -446,10 +546,7 @@ mod tests { #[test] fn test_encode_path_segment_already_encoded() { - // LLM might double-encode by passing pre-encoded values let encoded = encode_path_segment("user%40gmail.com"); - // The % itself gets encoded to %25, so %40 becomes %2540 - // This prevents double-encoding issues at the HTTP layer assert!(encoded.contains("%2540")); } @@ -504,7 +601,6 @@ mod tests { #[test] fn test_validate_resource_name_query_injection() { - // LLMs might append query strings or fragments to resource names assert!(validate_resource_name("spaces/ABC?key=val").is_err()); assert!(validate_resource_name("spaces/ABC#fragment").is_err()); } @@ -523,13 +619,104 @@ mod tests { #[test] fn test_validate_resource_name_percent_bypass() { - // %2e%2e is .. assert!(validate_resource_name("%2e%2e").is_err()); assert!(validate_resource_name("spaces/%2e%2e/etc").is_err()); - // Just % should be rejected too assert!(validate_resource_name("spaces/100%").is_err()); } + // --- reject_dangerous_chars Unicode --- + + #[test] + fn test_reject_dangerous_chars_zero_width_space() { + assert!(reject_dangerous_chars("foo\u{200B}bar", "test").is_err()); + } + + #[test] + fn test_reject_dangerous_chars_bom() { + assert!(reject_dangerous_chars("foo\u{FEFF}bar", "test").is_err()); + } + + #[test] + fn test_reject_dangerous_chars_rtl_override() { + assert!(reject_dangerous_chars("foo\u{202E}bar", "test").is_err()); + } + + #[test] + fn test_reject_dangerous_chars_unicode_line_separator() { + assert!(reject_dangerous_chars("foo\u{2028}bar", "test").is_err()); + } + + #[test] + fn test_reject_dangerous_chars_paragraph_separator() { + assert!(reject_dangerous_chars("foo\u{2029}bar", "test").is_err()); + } + + #[test] + fn test_reject_dangerous_chars_zero_width_joiner() { + assert!(reject_dangerous_chars("foo\u{200D}bar", "test").is_err()); + } + + #[test] + fn test_reject_dangerous_chars_normal_unicode_ok() { + assert!(reject_dangerous_chars("日本語", "test").is_ok()); + assert!(reject_dangerous_chars("café", "test").is_ok()); + assert!(reject_dangerous_chars("αβγ", "test").is_ok()); + } + + // --- path validator Unicode --- + + #[test] + fn test_output_dir_rejects_zero_width_chars() { + assert!(validate_safe_output_dir("foo\u{200B}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_rtl_override() { + assert!(validate_safe_output_dir("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_unicode_line_separator() { + assert!(validate_safe_output_dir("foo\u{2028}bar").is_err()); + } + + // --- validate_resource_name Unicode --- + + #[test] + fn test_validate_resource_name_zero_width_chars() { + assert!(validate_resource_name("foo\u{200B}bar").is_err()); + assert!(validate_resource_name("foo\u{200D}bar").is_err()); + assert!(validate_resource_name("foo\u{FEFF}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_unicode_line_seps() { + assert!(validate_resource_name("foo\u{2028}bar").is_err()); + assert!(validate_resource_name("foo\u{2029}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_rtl_override() { + assert!(validate_resource_name("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_bidi_embedding() { + assert!(validate_resource_name("foo\u{202A}bar").is_err()); + assert!(validate_resource_name("foo\u{202B}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_homoglyphs_pass_through() { + assert!(validate_resource_name("spaces/ΑΒС").is_ok()); + } + + #[test] + fn test_validate_resource_name_overlong_accepted() { + let long = "a".repeat(10_000); + assert!(validate_resource_name(&long).is_ok()); + } + // --- validate_api_identifier --- #[test] @@ -566,4 +753,86 @@ mod tests { fn test_validate_api_identifier_empty() { assert!(validate_api_identifier("").is_err()); } + + // --- validate_safe_file_path --- + + #[test] + #[serial] + fn test_file_path_relative_is_ok() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + fs::write(canonical_dir.join("test.txt"), "data").unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("test.txt", "--upload"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[test] + #[serial] + fn test_file_path_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("../../etc/passwd", "--upload"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err(), "path traversal should be rejected"); + assert!( + result.unwrap_err().to_string().contains("outside"), + "error should mention 'outside'" + ); + } + + #[test] + fn test_file_path_rejects_control_chars() { + let result = validate_safe_file_path("file\x00.txt", "--output"); + assert!(result.is_err(), "null bytes should be rejected"); + } + + #[test] + #[serial] + fn test_file_path_rejects_symlink_escape() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + #[cfg(unix)] + { + let link_path = canonical_dir.join("escape"); + std::os::unix::fs::symlink("/tmp", &link_path).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("escape/secret.txt", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err(), "symlink escape should be rejected"); + } + } + + #[test] + #[serial] + fn test_file_path_rejects_traversal_via_nonexistent_prefix() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("doesnt_exist/../../etc/passwd", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!( + result.is_err(), + "traversal via non-existent prefix should be rejected" + ); + } } diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..ba5b337f --- /dev/null +++ b/deny.toml @@ -0,0 +1,49 @@ +# cargo-deny configuration +# https://embarkstudios.github.io/cargo-deny/ + +[graph] +targets = [ + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-pc-windows-msvc", +] + +# Advisories — checks for known vulnerable crate versions +[advisories] +ignore = [] +db-urls = ["https://github.com/rustsec/advisory-db"] + +# Licenses — allowlist of acceptable licenses +[licenses] +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", + "OpenSSL", + "MPL-2.0", + "CC0-1.0", + "BSL-1.0", +] + +# Bans — reject problematic and duplicate crates +[bans] +multiple-versions = "warn" +wildcards = "deny" +deny = [] + +# Sources — restrict where crates can come from +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index", "https://index.crates.io/"] +allow-git = [] diff --git a/dist-workspace.toml b/dist-workspace.toml deleted file mode 100644 index f8cd418d..00000000 --- a/dist-workspace.toml +++ /dev/null @@ -1,44 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -[workspace] -members = ["cargo:."] - -# Config for 'cargo dist' -[dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.31.0" -# CI backends to support -ci = "github" -# The installers to generate for each app -installers = ["shell", "powershell", "npm"] -# Publish jobs to run -publish-jobs = ["npm"] -npm-scope = "@googleworkspace" -# Enable github attestations -github-attestations = true -npm-package = "cli" -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] -# Which actions to run on pull requests -pr-run-mode = "plan" -# Don't overwrite release.yml on `cargo dist init` (preserves custom npm registry config) -allow-dirty = ["ci"] -# The archive format to use for windows builds (defaults .zip) -# Using .zip routes through PowerShell's Expand-Archive, which correctly -# handles Windows paths. Using .tar.gz causes failures in Git Bash because -# MSYS tar interprets "C:" as a remote host (issue #152). -windows-archive = ".zip" -# The archive format to use for non-windows builds (defaults .tar.xz) -unix-archive = ".tar.gz" diff --git a/docs/skills.md b/docs/skills.md index bb718cbb..96fcae2e 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -26,6 +26,7 @@ Core Google Workspace API skills. | [gws-events](../skills/gws-events/SKILL.md) | Subscribe to Google Workspace events. | | [gws-modelarmor](../skills/gws-modelarmor/SKILL.md) | Google Model Armor: Filter user-generated content for safety. | | [gws-workflow](../skills/gws-workflow/SKILL.md) | Google Workflow: Cross-service productivity workflows. | +| [gws-script](../skills/gws-script/SKILL.md) | Manage Google Apps Script projects. | ## Helpers @@ -41,6 +42,7 @@ Shortcut commands for common operations. | [gws-gmail-reply](../skills/gws-gmail-reply/SKILL.md) | Gmail: Reply to a message (handles threading automatically). | | [gws-gmail-reply-all](../skills/gws-gmail-reply-all/SKILL.md) | Gmail: Reply-all to a message (handles threading automatically). | | [gws-gmail-forward](../skills/gws-gmail-forward/SKILL.md) | Gmail: Forward a message to new recipients. | +| [gws-gmail-read](../skills/gws-gmail-read/SKILL.md) | Gmail: Read a message and extract its body or headers. | | [gws-gmail-watch](../skills/gws-gmail-watch/SKILL.md) | Gmail: Watch for new emails and stream them as NDJSON. | | [gws-calendar-insert](../skills/gws-calendar-insert/SKILL.md) | Google Calendar: Create a new event. | | [gws-calendar-agenda](../skills/gws-calendar-agenda/SKILL.md) | Google Calendar: Show upcoming events across all calendars. | @@ -56,6 +58,7 @@ Shortcut commands for common operations. | [gws-workflow-email-to-task](../skills/gws-workflow-email-to-task/SKILL.md) | Google Workflow: Convert a Gmail message into a Google Tasks entry. | | [gws-workflow-weekly-digest](../skills/gws-workflow-weekly-digest/SKILL.md) | Google Workflow: Weekly summary: this week's meetings + unread email count. | | [gws-workflow-file-announce](../skills/gws-workflow-file-announce/SKILL.md) | Google Workflow: Announce a Drive file in a Chat space. | +| [gws-script-push](../skills/gws-script-push/SKILL.md) | Google Apps Script: Upload local files to an Apps Script project. | ## Personas diff --git a/flake.lock b/flake.lock index 1bb63f24..f123c7ec 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1773282481, - "narHash": "sha256-b/GV2ysM8mKHhinse2wz+uP37epUrSE+sAKXy/xvBY4=", + "lastModified": 1774709303, + "narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "fe416aaedd397cacb33a610b33d60ff2b431b127", + "rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index be51c9d5..0f465d77 100644 --- a/flake.nix +++ b/flake.nix @@ -11,8 +11,8 @@ let pkgs = nixpkgs.legacyPackages.${system}; - # Extract version from Cargo.toml - cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + # Extract version from CLI crate's Cargo.toml + cargoToml = builtins.fromTOML (builtins.readFile ./crates/google-workspace-cli/Cargo.toml); version = cargoToml.package.version; # System dependencies diff --git a/npm/.gitignore b/npm/.gitignore new file mode 100644 index 00000000..3c95b725 --- /dev/null +++ b/npm/.gitignore @@ -0,0 +1,2 @@ +# Downloaded binary (created during npm postinstall) +bin/ diff --git a/npm/install.js b/npm/install.js new file mode 100644 index 00000000..614b44a7 --- /dev/null +++ b/npm/install.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node + +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const { pipeline } = require("stream/promises"); +const { createWriteStream, mkdirSync, rmSync } = require("fs"); +const { spawnSync } = require("child_process"); +const { getPlatform } = require("./platform"); + +const INSTALL_DIR = path.join(__dirname, "bin"); + +/** + * Get the GitHub release download URL base for the current package version. + */ +function getDownloadUrl(artifactName) { + const { version } = require("./package.json"); + return `https://github.com/googleworkspace/cli/releases/download/v${version}/${artifactName}`; +} + +/** + * Strip ANSI escape sequences from a string. + */ +function sanitize(str) { + // eslint-disable-next-line no-control-regex + return String(str).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ""); +} + +/** + * Download a file using native fetch (Node 18+). + * + * NOTE: Native fetch does not respect HTTP_PROXY / HTTPS_PROXY environment + * variables. If proxy support is needed, consider using the `undici` ProxyAgent + * or a Node.js build with proxy support. + */ +async function download(url, dest) { + const res = await fetch(url, { redirect: "follow" }); + + if (!res.ok) { + throw new Error(`Failed to download ${url}: ${res.status} ${res.statusText}`); + } + + if (!res.body) { + throw new Error(`Failed to download ${url}: Response body is empty`); + } + + const fileStream = createWriteStream(dest); + // Convert web ReadableStream to Node stream and pipe + const { Readable } = require("stream"); + const nodeStream = Readable.fromWeb(res.body); + await pipeline(nodeStream, fileStream); +} + +/** + * Run a command and throw on failure. + */ +function run(cmd, args) { + const result = spawnSync(cmd, args, { stdio: "pipe" }); + if (result.error) { + throw new Error(`Failed to run ${cmd}: ${result.error.message}`); + } + if ((result.status ?? 1) !== 0) { + const stderr = result.stderr ? result.stderr.toString() : ""; + throw new Error( + `Command failed: ${cmd} ${args.join(" ")}\n${stderr}`, + ); + } +} + +/** + * Extract the archive to the install directory. + */ +function extract(archivePath, destDir) { + const isZip = archivePath.endsWith(".zip"); + const isTar = archivePath.includes(".tar."); + + if (isTar) { + run("tar", ["xf", archivePath, "-C", destDir]); + } else if (isZip) { + if (process.platform === "win32") { + // Use single-quoted PowerShell strings with doubled single-quote escaping + // to safely handle paths containing spaces and special characters. + const psArchive = archivePath.replace(/'/g, "''"); + const psDest = destDir.replace(/'/g, "''"); + run("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -LiteralPath '${psArchive}' -DestinationPath '${psDest}' -Force`, + ]); + } else { + run("unzip", ["-q", "-o", archivePath, "-d", destDir]); + } + } else { + throw new Error(`Unsupported archive format: ${archivePath}`); + } +} + +async function install() { + const platform = getPlatform(); + const { version } = require("./package.json"); + const url = getDownloadUrl(platform.artifact); + + // Check if the correct version is already installed + const binPath = path.join(INSTALL_DIR, platform.binary); + const versionFile = path.join(INSTALL_DIR, ".version"); + if (fs.existsSync(binPath) && fs.existsSync(versionFile)) { + const installed = fs.readFileSync(versionFile, "utf8").trim(); + if (installed === version) { + console.error(`gws v${version} is already installed, skipping.`); + return; + } + console.error(`Upgrading gws from v${installed} to v${version}`); + } + + // Clean and create install directory + if (fs.existsSync(INSTALL_DIR)) { + rmSync(INSTALL_DIR, { recursive: true, force: true }); + } + mkdirSync(INSTALL_DIR, { recursive: true }); + + // Download to a temp file + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gws-")); + const archiveName = path.basename(platform.artifact); + const tmpFile = path.join(tmpDir, archiveName); + + try { + console.error(`Downloading gws from ${url}`); + await download(url, tmpFile); + + // Verify SHA256 checksum + const sha256Url = `${url}.sha256`; + const sha256File = `${tmpFile}.sha256`; + console.error(`Verifying checksum from ${sha256Url}`); + await download(sha256Url, sha256File); + + const expectedHash = fs.readFileSync(sha256File, "utf8").trim().split(/\s+/)[0].toLowerCase(); + const fileBuffer = fs.readFileSync(tmpFile); + const actualHash = crypto.createHash("sha256").update(fileBuffer).digest("hex").toLowerCase(); + + if (actualHash !== expectedHash) { + throw new Error( + `SHA256 checksum mismatch!\n Expected: ${expectedHash}\n Actual: ${actualHash}\nThe downloaded binary may have been tampered with.`, + ); + } + console.error("Checksum verified ✓"); + + console.error(`Extracting to ${INSTALL_DIR}`); + extract(tmpFile, INSTALL_DIR); + + // Make binary executable on Unix + if (process.platform !== "win32") { + fs.chmodSync(binPath, 0o755); + } + + console.error(`gws v${version} has been installed!`); + fs.writeFileSync(versionFile, version); + } finally { + // Clean up temp files + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +install().catch((err) => { + console.error(`Error installing gws: ${sanitize(err.message)}`); + process.exit(1); +}); diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 00000000..46293ffa --- /dev/null +++ b/npm/package.json @@ -0,0 +1,79 @@ +{ + "name": "@googleworkspace/cli", + "description": "Google Workspace CLI — dynamic command surface from Discovery Service", + "version": "0.22.5", + "license": "Apache-2.0", + "author": "Justin Poehnelt", + "repository": { + "type": "git", + "url": "https://github.com/googleworkspace/cli.git" + }, + "homepage": "https://github.com/googleworkspace/cli", + "bugs": { + "url": "https://github.com/googleworkspace/cli/issues" + }, + "bin": { + "gws": "run.js" + }, + "scripts": { + "postinstall": "node install.js" + }, + "engines": { + "node": ">=18" + }, + "preferUnplugged": true, + "keywords": [ + "cli", + "google-workspace", + "google", + "google-api", + "google-drive", + "google-gmail", + "google-sheets", + "google-calendar", + "google-docs", + "google-chat", + "google-admin", + "gsuite", + "discovery-api", + "ai-agent", + "agent-skills", + "automation", + "oauth2", + "rust" + ], + "publishConfig": { + "provenance": true, + "registry": "https://wombat-dressing-room.appspot.com" + }, + "supportedPlatforms": { + "aarch64-apple-darwin": { + "artifact": "google-workspace-cli-aarch64-apple-darwin.tar.gz", + "binary": "gws" + }, + "x86_64-apple-darwin": { + "artifact": "google-workspace-cli-x86_64-apple-darwin.tar.gz", + "binary": "gws" + }, + "aarch64-unknown-linux-gnu": { + "artifact": "google-workspace-cli-aarch64-unknown-linux-gnu.tar.gz", + "binary": "gws" + }, + "aarch64-unknown-linux-musl": { + "artifact": "google-workspace-cli-aarch64-unknown-linux-musl.tar.gz", + "binary": "gws" + }, + "x86_64-unknown-linux-gnu": { + "artifact": "google-workspace-cli-x86_64-unknown-linux-gnu.tar.gz", + "binary": "gws" + }, + "x86_64-unknown-linux-musl": { + "artifact": "google-workspace-cli-x86_64-unknown-linux-musl.tar.gz", + "binary": "gws" + }, + "x86_64-pc-windows-msvc": { + "artifact": "google-workspace-cli-x86_64-pc-windows-msvc.zip", + "binary": "gws.exe" + } + } +} diff --git a/npm/platform.js b/npm/platform.js new file mode 100644 index 00000000..cf90d471 --- /dev/null +++ b/npm/platform.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +"use strict"; + +const os = require("os"); +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); + +const { supportedPlatforms } = require("./package.json"); + +/** + * Map Node.js os.type() and os.arch() to Rust-style target triples. + */ +function getPlatformKey() { + const rawOs = os.type(); + const rawArch = os.arch(); + + let osType; + switch (rawOs) { + case "Windows_NT": + osType = "pc-windows-msvc"; + break; + case "Darwin": + osType = "apple-darwin"; + break; + case "Linux": + osType = "unknown-linux-gnu"; + break; + default: + throw new Error(`Unsupported operating system: ${rawOs}`); + } + + let arch; + switch (rawArch) { + case "x64": + arch = "x86_64"; + break; + case "arm64": + arch = "aarch64"; + break; + default: + throw new Error(`Unsupported architecture: ${rawArch}`); + } + + // On Linux, try to detect musl libc + if (rawOs === "Linux") { + try { + const result = spawnSync("ldd", ["--version"], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + // musl ldd prints version info to stderr + const output = (result.stdout || "") + (result.stderr || ""); + if (output.toLowerCase().includes("musl")) { + osType = "unknown-linux-musl"; + } + } catch { + // If ldd fails, assume glibc + } + } + + const key = `${arch}-${osType}`; + + if (!supportedPlatforms[key]) { + // Try musl fallback on Linux if glibc binary is not available + if (rawOs === "Linux") { + const muslKey = `${arch}-unknown-linux-musl`; + if (supportedPlatforms[muslKey]) { + return muslKey; + } + } + throw new Error( + `Unsupported platform: ${key}\nSupported platforms: ${Object.keys(supportedPlatforms).join(", ")}`, + ); + } + + return key; +} + +function getPlatform() { + const key = getPlatformKey(); + return supportedPlatforms[key]; +} + +module.exports = { getPlatform, getPlatformKey }; diff --git a/npm/run.js b/npm/run.js new file mode 100644 index 00000000..4d6e84db --- /dev/null +++ b/npm/run.js @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const { spawnSync } = require("child_process"); +const { getPlatform } = require("./platform"); + +const platform = getPlatform(); +const binPath = path.join(__dirname, "bin", platform.binary); + +if (!fs.existsSync(binPath)) { + console.error( + `gws binary not found at ${binPath}\nAuto-installing...` + ); + const install = spawnSync(process.execPath, [path.join(__dirname, "install.js")], { + cwd: __dirname, + stdio: "inherit", + }); + if (install.status !== 0) { + process.exit(install.status ?? 1); + } +} + +const result = spawnSync(binPath, process.argv.slice(2), { + cwd: process.cwd(), + stdio: "inherit", +}); + +if (result.error) { + console.error(`Error running gws: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/package.json b/package.json index f62a3e6c..2a556aad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@googleworkspace/cli", - "version": "0.16.0", + "version": "0.22.5", "private": true, "description": "Google Workspace CLI — dynamic command surface from Discovery Service", "license": "Apache-2.0", diff --git a/registry/personas.yaml b/registry/personas.yaml deleted file mode 100644 index 2aac86af..00000000 --- a/registry/personas.yaml +++ /dev/null @@ -1,167 +0,0 @@ -# Persona Packs — Role-based skill bundles for AI agents -# -# Each persona defines a role-based context with: -# - name: unique id (used as directory name: persona-{name}) -# - title: human-readable name -# - description: when to use this persona -# - services: which gws services this persona commonly uses -# - workflows: which workflow commands are relevant -# - instructions: step-by-step guidance for agents adopting this role -# - tips: useful reminders - -personas: - - name: exec-assistant - title: Executive Assistant - description: "Manage an executive's schedule, inbox, and communications." - services: [gmail, calendar, drive, chat] - workflows: ["+standup-report", "+meeting-prep", "+weekly-digest"] - instructions: - - "Start each day with `gws workflow +standup-report` to get the executive's agenda and open tasks." - - "Before each meeting, run `gws workflow +meeting-prep` to see attendees, description, and linked docs." - - "Triage the inbox with `gws gmail +triage --max 10` — prioritize emails from direct reports and leadership." - - "Schedule meetings with `gws calendar +insert` — always check for conflicts first using `gws calendar +agenda`." - - "Draft replies with `gws gmail +send` — keep tone professional and concise." - tips: - - "Always confirm calendar changes with the executive before committing." - - "Use `--format table` for quick visual scans of agenda and triage output." - - "Check `gws calendar +agenda --week` on Monday mornings for weekly planning." - - - name: project-manager - title: Project Manager - description: "Coordinate projects — track tasks, schedule meetings, and share docs." - services: [drive, sheets, calendar, gmail, chat] - workflows: ["+standup-report", "+weekly-digest", "+file-announce"] - instructions: - - "Start the week with `gws workflow +weekly-digest` for a snapshot of upcoming meetings and unread items." - - "Track project status in Sheets using `gws sheets +append` to log updates." - - "Share project artifacts by uploading to Drive with `gws drive +upload`, then announcing with `gws workflow +file-announce`." - - "Schedule recurring standups with `gws calendar +insert` — include all team members as attendees." - - "Send status update emails to stakeholders with `gws gmail +send`." - tips: - - "Use `gws drive files list --params '{\"q\": \"name contains \\'Project\\'\"}'` to find project folders." - - "Pipe triage output through `jq` for filtering by sender or subject." - - "Use `--dry-run` before any write operations to preview what will happen." - - - name: hr-coordinator - title: HR Coordinator - description: "Handle HR workflows — onboarding, announcements, and employee comms." - services: [gmail, calendar, drive, chat] - workflows: ["+email-to-task", "+file-announce"] - instructions: - - "For new hire onboarding, create calendar events for orientation sessions with `gws calendar +insert`." - - "Upload onboarding docs to a shared Drive folder with `gws drive +upload`." - - "Announce new hires in Chat spaces with `gws workflow +file-announce` to share their profile doc." - - "Convert email requests into tracked tasks with `gws workflow +email-to-task`." - - "Send bulk announcements with `gws gmail +send` — use clear subject lines." - tips: - - "Always use `--sanitize` for PII-sensitive operations." - - "Create a dedicated 'HR Onboarding' calendar for tracking orientation schedules." - - - name: sales-ops - title: Sales Operations - description: "Manage sales workflows — track deals, schedule calls, client comms." - services: [gmail, calendar, sheets, drive] - workflows: ["+meeting-prep", "+email-to-task", "+weekly-digest"] - instructions: - - "Prepare for client calls with `gws workflow +meeting-prep` to review attendees and agenda." - - "Log deal updates in a tracking spreadsheet with `gws sheets +append`." - - "Convert follow-up emails into tasks with `gws workflow +email-to-task`." - - "Share proposals by uploading to Drive with `gws drive +upload`." - - "Get a weekly sales pipeline summary with `gws workflow +weekly-digest`." - tips: - - "Use `gws gmail +triage --query 'from:client-domain.com'` to filter client emails." - - "Schedule follow-up calls immediately after meetings to maintain momentum." - - "Keep all client-facing documents in a dedicated shared Drive folder." - - - name: it-admin - title: IT Administrator - description: "Administer IT — monitor security and configure Workspace." - services: [gmail, drive, calendar] - workflows: ["+standup-report"] - instructions: - - "Start the day with `gws workflow +standup-report` to review any pending IT requests." - - "Monitor suspicious login activity and review audit logs." - - "Configure Drive sharing policies to enforce organizational security." - tips: - - "Always use `--dry-run` before bulk operations." - - "Review `gws auth status` regularly to verify service account permissions." - - - name: content-creator - title: Content Creator - description: "Create, organize, and distribute content across Workspace." - services: [docs, drive, gmail, chat, slides] - workflows: ["+file-announce"] - instructions: - - "Draft content in Google Docs with `gws docs +write`." - - "Organize content assets in Drive folders — use `gws drive files list` to browse." - - "Share finished content by announcing in Chat with `gws workflow +file-announce`." - - "Send content review requests via email with `gws gmail +send`." - - "Upload media assets to Drive with `gws drive +upload`." - tips: - - "Use `gws docs +write` for quick content updates — it handles the Docs API formatting." - - "Keep a 'Content Calendar' in a shared Sheet for tracking publication schedules." - - "Use `--format yaml` for human-readable output when debugging API responses." - - - name: customer-support - title: Customer Support Agent - description: "Manage customer support — track tickets, respond, escalate issues." - services: [gmail, sheets, chat, calendar] - workflows: ["+email-to-task", "+standup-report"] - instructions: - - "Triage the support inbox with `gws gmail +triage --query 'label:support'`." - - "Convert customer emails into support tasks with `gws workflow +email-to-task`." - - "Log ticket status updates in a tracking sheet with `gws sheets +append`." - - "Escalate urgent issues to the team Chat space." - - "Schedule follow-up calls with customers using `gws calendar +insert`." - tips: - - "Use `gws gmail +triage --labels` to see email categories at a glance." - - "Set up Gmail filters for auto-labeling support requests." - - "Use `--format table` for quick status dashboard views." - - - name: event-coordinator - title: Event Coordinator - description: "Plan and manage events — scheduling, invitations, and logistics." - services: [calendar, gmail, drive, chat, sheets] - workflows: ["+meeting-prep", "+file-announce", "+weekly-digest"] - instructions: - - "Create event calendar entries with `gws calendar +insert` — include location and attendee lists." - - "Prepare event materials and upload to Drive with `gws drive +upload`." - - "Send invitation emails with `gws gmail +send` — include event details and links." - - "Announce updates in Chat spaces with `gws workflow +file-announce`." - - "Track RSVPs and logistics in Sheets with `gws sheets +append`." - tips: - - "Use `gws calendar +agenda --days 30` for long-range event planning." - - "Create a dedicated calendar for each major event series." - - "Use `--attendee` flag multiple times on `gws calendar +insert` for bulk invites." - - - name: team-lead - title: Team Lead - description: "Lead a team — run standups, coordinate tasks, and communicate." - services: [calendar, gmail, chat, drive, sheets] - workflows: ["+standup-report", "+meeting-prep", "+weekly-digest", "+email-to-task"] - instructions: - - "Run daily standups with `gws workflow +standup-report` — share output in team Chat." - - "Prepare for 1:1s with `gws workflow +meeting-prep`." - - "Get weekly snapshots with `gws workflow +weekly-digest`." - - "Delegate email action items with `gws workflow +email-to-task`." - - "Track team OKRs in a shared Sheet with `gws sheets +append`." - tips: - - "Use `gws calendar +agenda --week --format table` for weekly team calendar views." - - "Pipe standup reports to Chat with `gws chat spaces messages create`." - - "Use `--sanitize` for any operations involving sensitive team data." - - - name: researcher - title: Researcher - description: "Organize research — manage references, notes, and collaboration." - services: [drive, docs, sheets, gmail] - workflows: ["+file-announce"] - instructions: - - "Organize research papers and notes in Drive folders." - - "Write research notes and summaries with `gws docs +write`." - - "Track research data in Sheets — use `gws sheets +append` for data logging." - - "Share findings with collaborators via `gws workflow +file-announce`." - - "Request peer reviews via `gws gmail +send`." - tips: - - "Use `gws drive files list` with search queries to find specific documents." - - "Keep a running log of experiments and findings in a shared Sheet." - - "Use `--format csv` when exporting data for analysis tools." diff --git a/registry/recipes.yaml b/registry/recipes.yaml deleted file mode 100644 index e407404e..00000000 --- a/registry/recipes.yaml +++ /dev/null @@ -1,560 +0,0 @@ -# Curated Recipe Registry — Real-world Google Workspace workflows -# -# Each recipe defines a reusable multi-step task with: -# - name: unique id (directory: recipe-{name}) -# - title: human-readable name -# - description: compact intent (under 130 chars) -# - category: domain -# - services: which gws services this recipe uses -# - steps: concrete gws commands -# - caution: optional warning for destructive operations - -recipes: - - - # ============================================================ - # GMAIL WORKFLOWS - # ============================================================ - - name: label-and-archive-emails - title: Label and Archive Gmail Threads - description: "Apply Gmail labels to matching messages and archive them to keep your inbox clean." - category: productivity - services: [gmail] - steps: - - "Search for matching emails: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"from:notifications@service.com\"}' --format table`" - - "Apply a label: `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"addLabelIds\": [\"LABEL_ID\"]}'`" - - "Archive (remove from inbox): `gws gmail users messages modify --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}' --json '{\"removeLabelIds\": [\"INBOX\"]}'`" - - - - - name: draft-email-from-doc - title: Draft a Gmail Message from a Google Doc - description: "Read content from a Google Doc and use it as the body of a Gmail message." - category: productivity - services: [docs, gmail] - steps: - - "Get the document content: `gws docs documents get --params '{\"documentId\": \"DOC_ID\"}'`" - - "Copy the text from the body content" - - "Send the email: `gws gmail +send --to recipient@example.com --subject 'Newsletter Update' --body 'CONTENT_FROM_DOC'`" - - # ============================================================ - # GOOGLE DRIVE WORKFLOWS - # ============================================================ - - name: organize-drive-folder - title: Organize Files into Google Drive Folders - description: "Create a Google Drive folder structure and move files into the right locations." - category: productivity - services: [drive] - steps: - - "Create a project folder: `gws drive files create --json '{\"name\": \"Q2 Project\", \"mimeType\": \"application/vnd.google-apps.folder\"}'`" - - "Create sub-folders: `gws drive files create --json '{\"name\": \"Documents\", \"mimeType\": \"application/vnd.google-apps.folder\", \"parents\": [\"PARENT_FOLDER_ID\"]}'`" - - "Move existing files into folder: `gws drive files update --params '{\"fileId\": \"FILE_ID\", \"addParents\": \"FOLDER_ID\", \"removeParents\": \"OLD_PARENT_ID\"}'`" - - "Verify structure: `gws drive files list --params '{\"q\": \"FOLDER_ID in parents\"}' --format table`" - - - name: share-folder-with-team - title: Share a Google Drive Folder with a Team - description: "Share a Google Drive folder and all its contents with a list of collaborators." - category: productivity - services: [drive] - steps: - - "Find the folder: `gws drive files list --params '{\"q\": \"name = '\\''Project X'\\'' and mimeType = '\\''application/vnd.google-apps.folder'\\''\"}'`" - - "Share as editor: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"colleague@company.com\"}'`" - - "Share as viewer: `gws drive permissions create --params '{\"fileId\": \"FOLDER_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"stakeholder@company.com\"}'`" - - "Verify permissions: `gws drive permissions list --params '{\"fileId\": \"FOLDER_ID\"}' --format table`" - - - name: email-drive-link - title: Email a Google Drive File Link - description: "Share a Google Drive file and email the link with a message to recipients." - category: productivity - services: [drive, gmail] - steps: - - "Find the file: `gws drive files list --params '{\"q\": \"name = '\\''Quarterly Report'\\''\"}'`" - - "Share the file: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"client@example.com\"}'`" - - "Email the link: `gws gmail +send --to client@example.com --subject 'Quarterly Report' --body 'Hi, please find the report here: https://docs.google.com/document/d/FILE_ID'`" - - # ============================================================ - # GOOGLE DOCS WORKFLOWS - # ============================================================ - - name: create-doc-from-template - title: Create a Google Doc from a Template - description: "Copy a Google Docs template, fill in content, and share with collaborators." - category: productivity - services: [drive, docs] - steps: - - "Copy the template: `gws drive files copy --params '{\"fileId\": \"TEMPLATE_DOC_ID\"}' --json '{\"name\": \"Project Brief - Q2 Launch\"}'`" - - "Get the new doc ID from the response" - - "Add content: `gws docs +write --document-id NEW_DOC_ID --text '## Project: Q2 Launch\n\n### Objective\nLaunch the new feature by end of Q2.'`" - - "Share with team: `gws drive permissions create --params '{\"fileId\": \"NEW_DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" - - # ============================================================ - # GOOGLE SHEETS WORKFLOWS - # ============================================================ - - name: create-expense-tracker - title: Create a Google Sheets Expense Tracker - description: "Set up a Google Sheets spreadsheet for tracking expenses with headers and initial entries." - category: productivity - services: [sheets, drive] - steps: - - "Create spreadsheet: `gws drive files create --json '{\"name\": \"Expense Tracker 2025\", \"mimeType\": \"application/vnd.google-apps.spreadsheet\"}'`" - - "Add headers: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"Date\", \"Category\", \"Description\", \"Amount\"]'`" - - "Add first entry: `gws sheets +append --spreadsheet SHEET_ID --range 'Sheet1' --values '[\"2025-01-15\", \"Travel\", \"Flight to NYC\", \"450.00\"]'`" - - "Share with manager: `gws drive permissions create --params '{\"fileId\": \"SHEET_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"manager@company.com\"}'`" - - - name: copy-sheet-for-new-month - title: Copy a Google Sheet for a New Month - description: "Duplicate a Google Sheets template tab for a new month of tracking." - category: productivity - services: [sheets] - steps: - - "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`" - - "Copy the template sheet: `gws sheets spreadsheets sheets copyTo --params '{\"spreadsheetId\": \"SHEET_ID\", \"sheetId\": 0}' --json '{\"destinationSpreadsheetId\": \"SHEET_ID\"}'`" - - "Rename the new tab: `gws sheets spreadsheets batchUpdate --params '{\"spreadsheetId\": \"SHEET_ID\"}' --json '{\"requests\": [{\"updateSheetProperties\": {\"properties\": {\"sheetId\": 123, \"title\": \"February 2025\"}, \"fields\": \"title\"}}]}'`" - - # ============================================================ - # GOOGLE CALENDAR WORKFLOWS - # ============================================================ - - name: block-focus-time - title: Block Focus Time on Google Calendar - description: "Create recurring focus time blocks on Google Calendar to protect deep work hours." - category: scheduling - services: [calendar] - steps: - - "Create recurring focus block: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Focus Time\", \"description\": \"Protected deep work block\", \"start\": {\"dateTime\": \"2025-01-20T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-20T11:00:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\"], \"transparency\": \"opaque\"}'`" - - "Verify it shows as busy: `gws calendar +agenda`" - - - name: reschedule-meeting - title: Reschedule a Google Calendar Meeting - description: "Move a Google Calendar event to a new time and automatically notify all attendees." - category: scheduling - services: [calendar] - steps: - - "Find the event: `gws calendar +agenda`" - - "Get event details: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - "Update the time: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"start\": {\"dateTime\": \"2025-01-22T14:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2025-01-22T15:00:00\", \"timeZone\": \"America/New_York\"}}'`" - - # ============================================================ - # EMAIL / GMAIL - # ============================================================ - - - - name: create-gmail-filter - title: Create a Gmail Filter - description: "Create a Gmail filter to automatically label, star, or categorize incoming messages." - category: productivity - services: [gmail] - steps: - - "List existing labels: `gws gmail users labels list --params '{\"userId\": \"me\"}' --format table`" - - "Create a new label: `gws gmail users labels create --params '{\"userId\": \"me\"}' --json '{\"name\": \"Receipts\"}'`" - - "Create a filter: `gws gmail users settings filters create --params '{\"userId\": \"me\"}' --json '{\"criteria\": {\"from\": \"receipts@example.com\"}, \"action\": {\"addLabelIds\": [\"LABEL_ID\"], \"removeLabelIds\": [\"INBOX\"]}}'`" - - "Verify filter: `gws gmail users settings filters list --params '{\"userId\": \"me\"}' --format table`" - - # ============================================================ - # CALENDAR / SCHEDULING - # ============================================================ - - - - name: schedule-recurring-event - title: Schedule a Recurring Meeting - description: "Create a recurring Google Calendar event with attendees." - category: scheduling - services: [calendar] - steps: - - "Create recurring event: `gws calendar events insert --params '{\"calendarId\": \"primary\"}' --json '{\"summary\": \"Weekly Standup\", \"start\": {\"dateTime\": \"2024-03-18T09:00:00\", \"timeZone\": \"America/New_York\"}, \"end\": {\"dateTime\": \"2024-03-18T09:30:00\", \"timeZone\": \"America/New_York\"}, \"recurrence\": [\"RRULE:FREQ=WEEKLY;BYDAY=MO\"], \"attendees\": [{\"email\": \"team@company.com\"}]}'`" - - "Verify it was created: `gws calendar +agenda --days 14 --format table`" - - - name: find-free-time - title: Find Free Time Across Calendars - description: "Query Google Calendar free/busy status for multiple users to find a meeting slot." - category: scheduling - services: [calendar] - steps: - - "Query free/busy: `gws calendar freebusy query --json '{\"timeMin\": \"2024-03-18T08:00:00Z\", \"timeMax\": \"2024-03-18T18:00:00Z\", \"items\": [{\"id\": \"user1@company.com\"}, {\"id\": \"user2@company.com\"}]}'`" - - "Review the output to find overlapping free slots" - - "Create event in the free slot: `gws calendar +insert --summary 'Meeting' --attendee user1@company.com --attendee user2@company.com --start '2024-03-18T14:00:00' --end '2024-03-18T14:30:00'`" - - # ============================================================ - # DRIVE / FILE MANAGEMENT - # ============================================================ - - name: bulk-download-folder - title: Bulk Download Drive Folder - description: "List and download all files from a Google Drive folder." - category: productivity - services: [drive] - steps: - - "List files in folder: `gws drive files list --params '{\"q\": \"'\\''FOLDER_ID'\\'' in parents\"}' --format json`" - - "Download each file: `gws drive files get --params '{\"fileId\": \"FILE_ID\", \"alt\": \"media\"}' -o filename.ext`" - - "Export Google Docs as PDF: `gws drive files export --params '{\"fileId\": \"FILE_ID\", \"mimeType\": \"application/pdf\"}' -o document.pdf`" - - - name: find-large-files - title: Find Largest Files in Drive - description: "Identify large Google Drive files consuming storage quota." - category: productivity - services: [drive] - steps: - - "List files sorted by size: `gws drive files list --params '{\"orderBy\": \"quotaBytesUsed desc\", \"pageSize\": 20, \"fields\": \"files(id,name,size,mimeType,owners)\"}' --format table`" - - "Review the output and identify files to archive or move" - - - name: create-shared-drive - title: Create and Configure a Shared Drive - description: "Create a Google Shared Drive and add members with appropriate roles." - category: productivity - services: [drive] - steps: - - "Create shared drive: `gws drive drives create --params '{\"requestId\": \"unique-id-123\"}' --json '{\"name\": \"Project X\"}'`" - - "Add a member: `gws drive permissions create --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"member@company.com\"}'`" - - "List members: `gws drive permissions list --params '{\"fileId\": \"DRIVE_ID\", \"supportsAllDrives\": true}'`" - - - - # ============================================================ - # SHEETS / DATA - # ============================================================ - - name: log-deal-update - title: Log Deal Update to Sheet - description: "Append a deal status update to a Google Sheets sales tracking spreadsheet." - category: sales - services: [sheets, drive] - steps: - - "Find the tracking sheet: `gws drive files list --params '{\"q\": \"name = '\\''Sales Pipeline'\\'' and mimeType = '\\''application/vnd.google-apps.spreadsheet'\\''\"}'`" - - "Read current data: `gws sheets +read --spreadsheet SHEET_ID --range \"Pipeline!A1:F\"`" - - "Append new row: `gws sheets +append --spreadsheet SHEET_ID --range 'Pipeline' --values '[\"2024-03-15\", \"Acme Corp\", \"Proposal Sent\", \"50,000ドル\", \"Q2\", \"jdoe\"]'`" - - - name: collect-form-responses - title: Check Form Responses - description: "Retrieve and review responses from a Google Form." - category: productivity - services: [forms] - steps: - - "List forms: `gws forms forms list` (if you don't have the form ID)" - - "Get form details: `gws forms forms get --params '{\"formId\": \"FORM_ID\"}'`" - - "Get responses: `gws forms forms responses list --params '{\"formId\": \"FORM_ID\"}' --format table`" - - # ============================================================ - # CHAT / TEAM COMMUNICATION - # ============================================================ - # ============================================================ - # ENGINEERING - # ============================================================ - - name: post-mortem-setup - title: Set Up Post-Mortem - description: "Create a Google Docs post-mortem, schedule a Google Calendar review, and notify via Chat." - category: engineering - services: [docs, calendar, chat] - steps: - - "Create post-mortem doc: `gws docs +write --title 'Post-Mortem: [Incident]' --body '## Summary\\n\\n## Timeline\\n\\n## Root Cause\\n\\n## Action Items'`" - - "Schedule review meeting: `gws calendar +insert --summary 'Post-Mortem Review: [Incident]' --attendee team@company.com --start '2026-03-16T14:00:00' --end '2026-03-16T15:00:00'`" - - "Notify in Chat: `gws chat +send --space spaces/ENG_SPACE --text '🔍 Post-mortem scheduled for [Incident].'`" - - # ============================================================ - # TASKS - # ============================================================ - - name: create-task-list - title: Create a Task List and Add Tasks - description: "Set up a new Google Tasks list with initial tasks." - category: productivity - services: [tasks] - steps: - - "Create task list: `gws tasks tasklists insert --json '{\"title\": \"Q2 Goals\"}'`" - - "Add a task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Review Q1 metrics\", \"notes\": \"Pull data from analytics dashboard\", \"due\": \"2024-04-01T00:00:00Z\"}'`" - - "Add another task: `gws tasks tasks insert --params '{\"tasklist\": \"TASKLIST_ID\"}' --json '{\"title\": \"Draft Q2 OKRs\"}'`" - - "List tasks: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\"}' --format table`" - - - name: review-overdue-tasks - title: Review Overdue Tasks - description: "Find Google Tasks that are past due and need attention." - category: productivity - services: [tasks] - steps: - - "List task lists: `gws tasks tasklists list --format table`" - - "List tasks with status: `gws tasks tasks list --params '{\"tasklist\": \"TASKLIST_ID\", \"showCompleted\": false}' --format table`" - - "Review due dates and prioritize overdue items" - - # ============================================================ - # CONTACTS / PEOPLE - # ============================================================ - - - # ============================================================ - # EVENT SUBSCRIPTIONS - # ============================================================ - - name: watch-drive-changes - title: Watch for Drive Changes - description: "Subscribe to change notifications on a Google Drive file or folder." - category: engineering - services: [events] - steps: - - "Create subscription: `gws events subscriptions create --json '{\"targetResource\": \"//drive.googleapis.com/drives/DRIVE_ID\", \"eventTypes\": [\"google.workspace.drive.file.v1.updated\"], \"notificationEndpoint\": {\"pubsubTopic\": \"projects/PROJECT/topics/TOPIC\"}, \"payloadOptions\": {\"includeResource\": true}}'`" - - "List active subscriptions: `gws events subscriptions list`" - - "Renew before expiry: `gws events +renew --subscription SUBSCRIPTION_ID`" - - # ============================================================ - # CLASSROOM - # ============================================================ - - name: create-classroom-course - title: Create a Google Classroom Course - description: "Create a Google Classroom course and invite students." - category: education - services: [classroom] - steps: - - "Create the course: `gws classroom courses create --json '{\"name\": \"Introduction to CS\", \"section\": \"Period 1\", \"room\": \"Room 101\", \"ownerId\": \"me\"}'`" - - "Invite a student: `gws classroom invitations create --json '{\"courseId\": \"COURSE_ID\", \"userId\": \"student@school.edu\", \"role\": \"STUDENT\"}'`" - - "List enrolled students: `gws classroom courses students list --params '{\"courseId\": \"COURSE_ID\"}' --format table`" - - # ============================================================ - # MEET - # ============================================================ - - name: create-meet-space - title: Create a Google Meet Conference - description: "Create a Google Meet meeting space and share the join link." - category: scheduling - services: [meet, gmail] - steps: - - "Create meeting space: `gws meet spaces create --json '{\"config\": {\"accessType\": \"OPEN\"}}'`" - - "Copy the meeting URI from the response" - - "Email the link: `gws gmail +send --to team@company.com --subject 'Join the meeting' --body 'Join here: MEETING_URI'`" - - - name: review-meet-participants - title: Review Google Meet Attendance - description: "Review who attended a Google Meet conference and for how long." - category: productivity - services: [meet] - steps: - - "List recent conferences: `gws meet conferenceRecords list --format table`" - - "List participants: `gws meet conferenceRecords participants list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID\"}' --format table`" - - "Get session details: `gws meet conferenceRecords participants participantSessions list --params '{\"parent\": \"conferenceRecords/CONFERENCE_ID/participants/PARTICIPANT_ID\"}' --format table`" - - # ============================================================ - # KEEP - # ============================================================ - # ============================================================ - # SLIDES - # ============================================================ - - name: create-presentation - title: Create a Google Slides Presentation - description: "Create a new Google Slides presentation and add initial slides." - category: productivity - services: [slides] - steps: - - "Create presentation: `gws slides presentations create --json '{\"title\": \"Quarterly Review Q2\"}'`" - - "Get the presentation ID from the response" - - "Share with team: `gws drive permissions create --params '{\"fileId\": \"PRESENTATION_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"team@company.com\"}'`" - - - - # ============================================================ - # CONSUMER PRODUCTIVITY - # ============================================================ - - name: save-email-attachments - title: Save Gmail Attachments to Google Drive - description: "Find Gmail messages with attachments and save them to a Google Drive folder." - category: productivity - services: [gmail, drive] - steps: - - "Search for emails with attachments: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"has:attachment from:client@example.com\"}' --format table`" - - "Get message details: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MESSAGE_ID\"}'`" - - "Download attachment: `gws gmail users messages attachments get --params '{\"userId\": \"me\", \"messageId\": \"MESSAGE_ID\", \"id\": \"ATTACHMENT_ID\"}'`" - - "Upload to Drive folder: `gws drive +upload --file ./attachment.pdf --parent FOLDER_ID`" - - # ============================================================ - # CROSS-SERVICE WORKFLOWS - # ============================================================ - - name: send-team-announcement - title: Announce via Gmail and Google Chat - description: "Send a team announcement via both Gmail and a Google Chat space." - category: communication - services: [gmail, chat] - steps: - - "Send email: `gws gmail +send --to team@company.com --subject 'Important Update' --body 'Please review the attached policy changes.'`" - - "Post in Chat: `gws chat +send --space spaces/TEAM_SPACE --text '📢 Important Update: Please check your email for policy changes.'`" - - - name: create-feedback-form - title: Create and Share a Google Form - description: "Create a Google Form for feedback and share it via Gmail." - category: productivity - services: [forms, gmail] - steps: - - "Create form: `gws forms forms create --json '{\"info\": {\"title\": \"Event Feedback\", \"documentTitle\": \"Event Feedback Form\"}}'`" - - "Get the form URL from the response (responderUri field)" - - "Email the form: `gws gmail +send --to attendees@company.com --subject 'Please share your feedback' --body 'Fill out the form: FORM_URL'`" - - - name: sync-contacts-to-sheet - title: Export Google Contacts to Sheets - description: "Export Google Contacts directory to a Google Sheets spreadsheet." - category: productivity - services: [people, sheets] - steps: - - "List contacts: `gws people people listDirectoryPeople --params '{\"readMask\": \"names,emailAddresses,phoneNumbers\", \"sources\": [\"DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE\"], \"pageSize\": 100}' --format json`" - - "Create a sheet: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Name\", \"Email\", \"Phone\"]'`" - - "Append each contact row: `gws sheets +append --spreadsheet SHEET_ID --range 'Contacts' --values '[\"Jane Doe\", \"jane@company.com\", \"+1-555-0100\"]'`" - - # ============================================================ - # CONSUMER — COLLABORATION - # ============================================================ - - name: share-event-materials - title: Share Files with Meeting Attendees - description: "Share Google Drive files with all attendees of a Google Calendar event." - category: productivity - services: [calendar, drive] - steps: - - "Get event attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - "Share file with each attendee: `gws drive permissions create --params '{\"fileId\": \"FILE_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"attendee@company.com\"}'`" - - "Verify sharing: `gws drive permissions list --params '{\"fileId\": \"FILE_ID\"}' --format table`" - # ============================================================ - # GMAIL — ORGANIZATION - # ============================================================ - - name: create-vacation-responder - title: Set Up a Gmail Vacation Responder - description: "Enable a Gmail out-of-office auto-reply with a custom message and date range." - category: productivity - services: [gmail] - steps: - - "Enable vacation responder: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": true, \"responseSubject\": \"Out of Office\", \"responseBodyPlainText\": \"I am out of the office until Jan 20. For urgent matters, contact backup@company.com.\", \"restrictToContacts\": false, \"restrictToDomain\": false}'`" - - "Verify settings: `gws gmail users settings getVacation --params '{\"userId\": \"me\"}'`" - - "Disable when back: `gws gmail users settings updateVacation --params '{\"userId\": \"me\"}' --json '{\"enableAutoReply\": false}'`" - - # ============================================================ - # DRIVE — FILE OPERATIONS - # ============================================================ - # ============================================================ - # SHEETS — DATA WORKFLOWS - # ============================================================ - - name: create-events-from-sheet - title: Create Google Calendar Events from a Sheet - description: "Read event data from a Google Sheets spreadsheet and create Google Calendar entries for each row." - category: productivity - services: [sheets, calendar] - steps: - - "Read event data: `gws sheets +read --spreadsheet SHEET_ID --range \"Events!A2:D\"`" - - "For each row, create a calendar event: `gws calendar +insert --summary 'Team Standup' --start '2026-01-20T09:00:00' --end '2026-01-20T09:30:00' --attendee alice@company.com --attendee bob@company.com`" - - # ============================================================ - # CALENDAR — PLANNING - # ============================================================ - - name: plan-weekly-schedule - title: Plan Your Weekly Google Calendar Schedule - description: "Review your Google Calendar week, identify gaps, and add events to fill them." - category: scheduling - services: [calendar] - steps: - - "Check this week's agenda: `gws calendar +agenda`" - - "Check free/busy for the week: `gws calendar freebusy query --json '{\"timeMin\": \"2025-01-20T00:00:00Z\", \"timeMax\": \"2025-01-25T00:00:00Z\", \"items\": [{\"id\": \"primary\"}]}'`" - - "Add a new event: `gws calendar +insert --summary 'Deep Work Block' --start '2026-01-21T14:00:00' --end '2026-01-21T16:00:00'`" - - "Review updated schedule: `gws calendar +agenda`" - # ============================================================ - # MULTI-SERVICE PROJECT SETUP - # ============================================================ - # ============================================================ - # DOCS — COLLABORATION - # ============================================================ - - name: share-doc-and-notify - title: Share a Google Doc and Notify Collaborators - description: "Share a Google Docs document with edit access and email collaborators the link." - category: productivity - services: [drive, docs, gmail] - steps: - - "Find the doc: `gws drive files list --params '{\"q\": \"name contains '\\''Project Brief'\\'' and mimeType = '\\''application/vnd.google-apps.document'\\''\"}'`" - - "Share with editor access: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"writer\", \"type\": \"user\", \"emailAddress\": \"reviewer@company.com\"}'`" - - "Email the link: `gws gmail +send --to reviewer@company.com --subject 'Please review: Project Brief' --body 'I have shared the project brief with you: https://docs.google.com/document/d/DOC_ID'`" - - # ============================================================ - # SHEETS — BACKUP - # ============================================================ - - name: backup-sheet-as-csv - title: Export a Google Sheet as CSV - description: "Export a Google Sheets spreadsheet as a CSV file for local backup or processing." - category: productivity - services: [sheets, drive] - steps: - - "Get spreadsheet details: `gws sheets spreadsheets get --params '{\"spreadsheetId\": \"SHEET_ID\"}'`" - - "Export as CSV: `gws drive files export --params '{\"fileId\": \"SHEET_ID\", \"mimeType\": \"text/csv\"}'`" - - "Or read values directly: `gws sheets +read --spreadsheet SHEET_ID --range 'Sheet1' --format csv`" - - # ============================================================ - # CALENDAR — TEAM - # ============================================================ - # ============================================================ - # DRIVE — CLEANUP - # ============================================================ - # ============================================================ - # GMAIL — ARCHIVING - # ============================================================ - - name: save-email-to-doc - title: Save a Gmail Message to Google Docs - description: "Save a Gmail message body into a Google Doc for archival or reference." - category: productivity - services: [gmail, docs] - steps: - - "Find the message: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"subject:important from:boss@company.com\"}' --format table`" - - "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`" - - "Create a doc with the content: `gws docs documents create --json '{\"title\": \"Saved Email - Important Update\"}'`" - - "Write the email body: `gws docs +write --document-id DOC_ID --text 'From: boss@company.com\nSubject: Important Update\n\n[EMAIL BODY]'`" - - # ============================================================ - # SHEETS — FORMULAS - # ============================================================ - # ============================================================ - # REPETITIVE WORKFLOWS - # ============================================================ - # ============================================================ - # GMAIL — AUTOMATED REPLIES - # ============================================================ - - - # ============================================================ - # DRIVE — BATCH OPERATIONS - # ============================================================ - - - # ============================================================ - # SHEETS — DATA SYNC - # ============================================================ - - name: compare-sheet-tabs - title: Compare Two Google Sheets Tabs - description: "Read data from two tabs in a Google Sheet to compare and identify differences." - category: productivity - services: [sheets] - steps: - - "Read the first tab: `gws sheets +read --spreadsheet SHEET_ID --range \"January!A1:D\"`" - - "Read the second tab: `gws sheets +read --spreadsheet SHEET_ID --range \"February!A1:D\"`" - - "Compare the data and identify changes" - - # ============================================================ - # CALENDAR — COORDINATION - # ============================================================ - - name: batch-invite-to-event - title: Add Multiple Attendees to a Calendar Event - description: "Add a list of attendees to an existing Google Calendar event and send notifications." - category: scheduling - services: [calendar] - steps: - - "Get the event: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - "Add attendees: `gws calendar events patch --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\", \"sendUpdates\": \"all\"}' --json '{\"attendees\": [{\"email\": \"alice@company.com\"}, {\"email\": \"bob@company.com\"}, {\"email\": \"carol@company.com\"}]}'`" - - "Verify attendees: `gws calendar events get --params '{\"calendarId\": \"primary\", \"eventId\": \"EVENT_ID\"}'`" - - # ============================================================ - # GMAIL — NOTIFICATION ROUTING - # ============================================================ - - name: forward-labeled-emails - title: Forward Labeled Gmail Messages - description: "Find Gmail messages with a specific label and forward them to another address." - category: productivity - services: [gmail] - steps: - - "Find labeled messages: `gws gmail users messages list --params '{\"userId\": \"me\", \"q\": \"label:needs-review\"}' --format table`" - - "Get message content: `gws gmail users messages get --params '{\"userId\": \"me\", \"id\": \"MSG_ID\"}'`" - - "Forward via new email: `gws gmail +send --to manager@company.com --subject 'FW: [Original Subject]' --body 'Forwarding for your review:\n\n[Original Message Body]'`" - - # ============================================================ - # DOCS + SHEETS — CROSS-SERVICE - # ============================================================ - - name: generate-report-from-sheet - title: Generate a Google Docs Report from Sheet Data - description: "Read data from a Google Sheet and create a formatted Google Docs report." - category: productivity - services: [sheets, docs, drive] - steps: - - "Read the data: `gws sheets +read --spreadsheet SHEET_ID --range \"Sales!A1:D\"`" - - "Create the report doc: `gws docs documents create --json '{\"title\": \"Sales Report - January 2025\"}'`" - - "Write the report: `gws docs +write --document-id DOC_ID --text '## Sales Report - January 2025\n\n### Summary\nTotal deals: 45\nRevenue: 125,000ドル\n\n### Top Deals\n1. Acme Corp - 25,000ドル\n2. Widget Inc - 18,000ドル'`" - - "Share with stakeholders: `gws drive permissions create --params '{\"fileId\": \"DOC_ID\"}' --json '{\"role\": \"reader\", \"type\": \"user\", \"emailAddress\": \"cfo@company.com\"}'`" - diff --git a/scripts/version-sync.sh b/scripts/version-sync.sh index 56dca38b..4dd83ec2 100755 --- a/scripts/version-sync.sh +++ b/scripts/version-sync.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Syncs the version from package.json into Cargo.toml and updates Cargo.lock. +# Syncs the version from package.json into all workspace Cargo.toml files, +# updates Cargo.lock, and regenerates skills. # Used by changesets/action as a custom version command. set -euo pipefail @@ -9,14 +10,28 @@ pnpm changeset version # Read the new version from package.json VERSION=$(node -p "require('./package.json').version") -# Update Cargo.toml version field +# Update version in all workspace crate Cargo.toml files # Uses awk to only change the version under [package], not other sections -awk -v ver="$VERSION" ' - /^\[package\]/ { in_pkg=1 } - /^\[/ && !/^\[package\]/ { in_pkg=0 } - in_pkg && /^version = / { 0ドル = "version = \"" ver "\"" } - { print } -' Cargo.toml> Cargo.toml.tmp && mv Cargo.toml.tmp Cargo.toml +for cargo_toml in crates/*/Cargo.toml; do + tmp=$(mktemp) + awk -v ver="$VERSION" ' + /^\[package\]/ { in_pkg=1 } + /^\[/ && !/^\[package\]/ { in_pkg=0 } + in_pkg && /^version = / { 0ドル = "version = \"" ver "\"" } + { print } + ' "$cargo_toml"> "$tmp" && mv "$tmp" "$cargo_toml" +done + +# Update inter-crate dependency versions (e.g. google-workspace = { version = "X.Y.Z", path = "..." }) +sed -i.bak -E "s/(google-workspace = \{ version = \")[^\"]+/1円${VERSION}/" crates/google-workspace-cli/Cargo.toml +rm -f crates/google-workspace-cli/Cargo.toml.bak + +# Update npm installer package.json version +node -e " + const pkg = require('./npm/package.json'); + pkg.version = '${VERSION}'; + require('fs').writeFileSync('./npm/package.json', JSON.stringify(pkg, null, 2) + '\n'); +" # Update Cargo.lock to match cargo generate-lockfile @@ -26,5 +41,9 @@ if command -v nix> /dev/null 2>&1; then nix flake lock --update-input nixpkgs fi +# Regenerate skills so metadata.version tracks the CLI version +cargo run -- generate-skills --output-dir skills + # Stage the changed files so changesets/action commits them -git add Cargo.toml Cargo.lock flake.nix flake.lock +git add crates/*/Cargo.toml Cargo.lock flake.nix flake.lock skills/ npm/package.json + diff --git a/skills/gws-admin-reports/SKILL.md b/skills/gws-admin-reports/SKILL.md index 7fb7e96b..4c9eae17 100644 --- a/skills/gws-admin-reports/SKILL.md +++ b/skills/gws-admin-reports/SKILL.md @@ -1,12 +1,13 @@ --- name: gws-admin-reports -version: 1.0.0 description: "Google Workspace Admin SDK: Audit logs and usage reports." metadata: + version: 0.22.5 openclaw: category: "productivity" requires: - bins: ["gws"] + bins: + - gws cliHelp: "gws admin-reports --help" --- diff --git a/skills/gws-calendar-agenda/SKILL.md b/skills/gws-calendar-agenda/SKILL.md index 5e777fef..89259ff3 100644 --- a/skills/gws-calendar-agenda/SKILL.md +++ b/skills/gws-calendar-agenda/SKILL.md @@ -1,12 +1,13 @@ --- name: gws-calendar-agenda -version: 1.0.0 description: "Google Calendar: Show upcoming events across all calendars." metadata: + version: 0.22.5 openclaw: category: "productivity" requires: - bins: ["gws"] + bins: + - gws cliHelp: "gws calendar +agenda --help" --- diff --git a/skills/gws-calendar-insert/SKILL.md b/skills/gws-calendar-insert/SKILL.md index bd0aeb2e..dd2989d8 100644 --- a/skills/gws-calendar-insert/SKILL.md +++ b/skills/gws-calendar-insert/SKILL.md @@ -1,12 +1,13 @@ --- name: gws-calendar-insert -version: 1.0.0 description: "Google Calendar: Create a new event." metadata: + version: 0.22.5 openclaw: category: "productivity" requires: - bins: ["gws"] + bins: + - gws cliHelp: "gws calendar +insert --help" --- @@ -33,18 +34,20 @@ gws calendar +insert --summary --start