feat: take over PR #18 integration

This commit is contained in:
Peter Steinberger 2025-12-26 15:35:15 +01:00
commit 9d8d36a94e
61 changed files with 9386 additions and 419 deletions

View File

@ -1,24 +1,33 @@
run:
timeout: 5m
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- typecheck
- unused
linters-settings:
govet:
enable-all: true
disable:
- fieldalignment
issues:
exclude-rules:
- path: _test\.go
linters:
- errcheck
settings:
govet:
enable-all: true
disable:
- fieldalignment
exclusions:
presets:
- std-error-handling
rules:
- path: _test\.go
linters:
- errcheck
- linters:
- errcheck
text: "Error return value of `fmt\\.Fprint"
- linters:
- errcheck
text: "Error return value of .*.Close"
- linters:
- staticcheck
text: "QF1001"

15
.lefthook.yml Normal file
View File

@ -0,0 +1,15 @@
pre-commit:
parallel: true
commands:
fmt-check:
run: make fmt-check
lint:
run: make lint
test:
run: make test
pre-push:
parallel: true
commands:
test:
run: make test

View File

@ -2,6 +2,15 @@
## 0.3.1 - Unreleased
### Added
- Resilience: automatic retries + circuit breaker for Google API calls (429/5xx).
- Gmail: batch ops + settings commands (autoforward, delegates, filters, forwarding, send-as, vacation).
- Calendar: colors, conflicts, search, multi-timezone time.
- Sheets: read/write/update/append/clear + create spreadsheets.
- Auth: browser-based accounts manager (`gog auth manage`).
- DX: shell completion (`gog completion ...`) and `--debug` logging.
## 0.3.0 - 2025-12-26
### Added

View File

@ -27,7 +27,7 @@ tools:
@mkdir -p $(TOOLS_DIR)
@GOBIN=$(TOOLS_DIR) go install mvdan.cc/gofumpt@v0.9.2
@GOBIN=$(TOOLS_DIR) go install golang.org/x/tools/cmd/goimports@v0.40.0
@GOBIN=$(TOOLS_DIR) go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.8
@GOBIN=$(TOOLS_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6
fmt: tools
@$(GOIMPORTS) -w .

641
README.md
View File

@ -1,73 +1,108 @@
# 📮 gog — Google in your terminal
# gog
Minimal Google CLI in Go for:
Google in your terminal - CLI for Gmail, Calendar, Drive, Contacts, Tasks, and Sheets.
- Gmail — search, threads/messages, labels, attachments, send + drafts (plain + HTML)
- Calendar — list/create/update/delete events, respond to invites, freebusy, ACL
- Drive — list/search/get, download/upload, move/rename/delete, share/permissions, URLs
- Contacts (People API) — list/search/get/create/update/delete, other contacts, Workspace directory
- Tasks — tasklists + tasks: lists/create/add/update/done/undo/delete/clear
- People — profile card (`people/me`)
## Features
## Install / Build
- **Gmail** - search threads, send emails, manage labels, drafts, filters, delegation, vacation settings, and watch (Pub/Sub push)
- **Calendar** - list/create/update events, detect conflicts, manage invitations, check free/busy status
- **Drive** - list/search/upload/download files, manage permissions, organize folders
- **Contacts** - search/create/update contacts, access Workspace directory
- **Tasks** - manage tasklists and tasks: create/add/update/done/undo/delete/clear
- **Sheets** - read/write/update spreadsheets, create new sheets
- **People** - access profile information
- **Multiple account support** - manage multiple Google accounts simultaneously
- **Secure credential storage** using OS keyring (Keychain on macOS, Secret Service on Linux, Credential Manager on Windows)
- **Auto-refreshing tokens** - authenticate once, use indefinitely
- **Parseable output** - JSON mode for scripting and automation
Install via Homebrew (tap):
## Installation
- `brew install steipete/tap/gogcli`
### Homebrew
Build locally:
```bash
brew install steipete/tap/gogcli
```
- `make`
### Build from Source
```bash
git clone https://github.com/steipete/gogcli.git
cd gogcli
make
```
Run:
- `./bin/gog --help`
```bash
./bin/gog --help
```
## Setup (OAuth)
## Quick Start
Before adding an account you need OAuth2 credentials from Google Cloud Console:
### 1. Get OAuth2 Credentials
1. Create a project (or select an existing one): https://console.cloud.google.com/projectcreate
Before adding an account, create OAuth2 credentials from Google Cloud Console:
1. Create a project: https://console.cloud.google.com/projectcreate
2. Enable the APIs you need:
- Gmail API: https://console.cloud.google.com/apis/api/gmail.googleapis.com
- Google Calendar API: https://console.cloud.google.com/apis/api/calendar-json.googleapis.com
- Google Drive API: https://console.cloud.google.com/apis/api/drive.googleapis.com
- People API (Contacts): https://console.cloud.google.com/apis/api/people.googleapis.com
- Google Tasks API: https://console.cloud.google.com/apis/api/tasks.googleapis.com
3. Set app name / branding (OAuth consent screen): https://console.cloud.google.com/auth/branding
4. If your app is in “Testing”, add test users (all Google accounts youll use with `gog`): https://console.cloud.google.com/auth/audience
5. Create an OAuth client: https://console.cloud.google.com/auth/clients
- Click “Create Client”
- Application type: “Desktop app”
- Download the JSON file (usually named like `client_secret_....apps.googleusercontent.com.json`)
- Google Sheets API: https://console.cloud.google.com/apis/api/sheets.googleapis.com
3. Configure OAuth consent screen: https://console.cloud.google.com/auth/branding
4. If your app is in "Testing", add test users: https://console.cloud.google.com/auth/audience
5. Create OAuth client:
- Go to https://console.cloud.google.com/auth/clients
- Click "Create Client"
- Application type: "Desktop app"
- Download the JSON file (usually named `client_secret_....apps.googleusercontent.com.json`)
Then:
### 2. Store Credentials
- Store the downloaded client JSON (no renaming required):
- `gog auth credentials ~/Downloads/client_secret_....json`
- Authorize your account (refresh token stored in OS keychain via `github.com/99designs/keyring`):
- `gog auth add you@gmail.com`
```bash
gog auth credentials ~/Downloads/client_secret_....json
```
Notes:
### 3. Authorize Your Account
- If no OS keychain backend is available (e.g. Linux/WSL/container), keyring can fall back to an encrypted on-disk store and may prompt for a password; for non-interactive runs set `GOG_KEYRING_PASSWORD`.
- Default is `--services all` (gmail, calendar, drive, contacts, tasks, people).
- To request fewer scopes: `gog auth add you@gmail.com --services drive,calendar`.
- If you add services later and Google doesnt return a refresh token, re-run with `--force-consent`.
- `gog auth add ...` overwrites the stored token for that email.
```bash
gog auth add you@gmail.com
```
## Accounts
This will open a browser window for OAuth authorization. The refresh token is stored securely in your system keychain.
Most API commands require an account selection:
### 4. Test Authentication
- `--account you@gmail.com`
- or set `GOG_ACCOUNT=you@gmail.com` to avoid repeating the flag.
```bash
export GOG_ACCOUNT=you@gmail.com
gog gmail labels list
```
## Configuration
### Account Selection
Specify the account using either a flag or environment variable:
```bash
# Via flag
gog gmail search 'newer_than:7d' --account you@gmail.com
# Via environment
export GOG_ACCOUNT=you@gmail.com
gog gmail search 'newer_than:7d'
```
List configured accounts:
- `gog auth list`
```bash
gog auth list
```
## Output (Parseable)
### Output
- Default: human-friendly tables on stdout.
- `--plain`: stable TSV on stdout (tabs preserved; best for piping to tools that expect `\t`).
@ -75,6 +110,315 @@ List configured accounts:
- Human-facing hints/progress go to stderr.
- Colors are enabled only in rich TTY output and are disabled automatically for `--json` and `--plain`.
### Service Scopes
By default, `gog auth add` requests access to all services (gmail, calendar, drive, contacts, tasks, sheets, people). To request fewer scopes:
```bash
gog auth add you@gmail.com --services drive,calendar
```
If you need to add services later and Google doesn't return a refresh token, re-run with `--force-consent`:
```bash
gog auth add you@gmail.com --services all --force-consent
```
### Environment Variables
- `GOG_ACCOUNT` - Default account email to use (avoids repeating `--account` flag)
- `GOG_JSON` - Default JSON output
- `GOG_PLAIN` - Default plain output
- `GOG_COLOR` - Color mode: `auto` (default), `always`, or `never`
- `GOG_KEYRING_PASSWORD` - Password for encrypted on-disk keyring (Linux/WSL/container environments without OS keychain)
## Security
### Credential Storage
OAuth credentials are stored securely in your system's keychain:
- **macOS**: Keychain Access
- **Linux**: Secret Service (GNOME Keyring, KWallet)
- **Windows**: Credential Manager
The CLI uses [github.com/99designs/keyring](https://github.com/99designs/keyring) for secure storage.
If no OS keychain backend is available (e.g., Linux/WSL/container), keyring can fall back to an encrypted on-disk store and may prompt for a password; for non-interactive runs set `GOG_KEYRING_PASSWORD`.
### Best Practices
- **Never commit OAuth client credentials** to version control
- Store client credentials outside your project directory
- Use different OAuth clients for development and production
- Re-authorize with `--force-consent` if you suspect token compromise
- Remove unused accounts with `gog auth remove <email>`
## Commands
### Authentication
```bash
gog auth credentials <path> # Store OAuth client credentials
gog auth add <email> # Authorize and store refresh token
gog auth list # List stored accounts
gog auth remove <email> # Remove a stored refresh token
gog auth manage # Open accounts manager in browser
gog auth tokens # Manage stored refresh tokens
```
### Gmail
```bash
# Search and read
gog gmail search 'newer_than:7d' --max 10
gog gmail thread <threadId>
gog gmail thread <threadId> --download-attachments # Download attachments to current dir
gog gmail get <messageId>
gog gmail get <messageId> --format metadata
gog gmail attachment <messageId> <attachmentId>
gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin
gog gmail url <threadId> # Print Gmail web URL
# Send and compose
gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback"
gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" --body-html "<p>Hello</p>"
gog gmail drafts list
gog gmail drafts create --to a@b.com --subject "Draft"
gog gmail drafts send <draftId>
# Labels
gog gmail labels list
gog gmail labels get INBOX --json # Includes message counts
gog gmail labels create "My Label"
gog gmail labels update <labelId> --name "New Name"
gog gmail labels delete <labelId>
# Batch operations
gog gmail batch mark-read --query 'older_than:30d'
gog gmail batch delete --query 'from:spam@example.com'
gog gmail batch label --query 'from:boss@example.com' --add-labels IMPORTANT
# Filters
gog gmail filters list
gog gmail filters create --from 'noreply@example.com' --label 'Notifications'
gog gmail filters delete <filterId>
# Settings
gog gmail autoforward get
gog gmail autoforward enable --email forward@example.com
gog gmail autoforward disable
gog gmail forwarding list
gog gmail forwarding add --email forward@example.com
gog gmail sendas list
gog gmail sendas create --email alias@example.com
gog gmail vacation get
gog gmail vacation enable --subject "Out of office" --message "..."
gog gmail vacation disable
# Delegation (G Suite/Workspace)
gog gmail delegates list
gog gmail delegates add --email delegate@example.com
gog gmail delegates remove --email delegate@example.com
# Watch (Pub/Sub push)
gog gmail watch start --topic projects/<p>/topics/<t> --label INBOX
gog gmail watch serve --bind 127.0.0.1 --token <shared> --hook-url http://127.0.0.1:18789/hooks/agent
gog gmail watch serve --bind 0.0.0.0 --verify-oidc --oidc-email <svc@...> --hook-url <url>
gog gmail history --since <historyId>
```
Gmail watch (Pub/Sub push):
- Create Pub/Sub topic + push subscription (OIDC preferred; shared token ok for dev).
- Full flow + payload details: `docs/watch.md`.
### Calendar
```bash
# Calendars
gog calendar calendars
gog calendar acl <calendarId> # List access control rules
gog calendar colors # List available event/calendar colors
gog calendar time --timezone America/New_York
# Events
gog calendar events <calendarId> --from 2025-01-01T00:00:00Z --to 2025-01-08T00:00:00Z --max 50
gog calendar events --all # Fetch events from all calendars
gog calendar event <calendarId> <eventId>
gog calendar search "meeting" --from 2025-01-01T00:00:00Z --to 2025-01-31T00:00:00Z --max 50
# Create and update
gog calendar create <calendarId> \
--summary "Meeting" \
--start 2025-01-15T10:00:00Z \
--end 2025-01-15T11:00:00Z
gog calendar create <calendarId> \
--summary "Team Sync" \
--start 2025-01-15T14:00:00Z \
--end 2025-01-15T15:00:00Z \
--organizer organizer@example.com \
--color 5
gog calendar update <calendarId> <eventId> \
--summary "Updated Meeting" \
--start 2025-01-15T11:00:00Z \
--end 2025-01-15T12:00:00Z
gog calendar delete <calendarId> <eventId>
# Invitations
gog calendar respond <calendarId> <eventId> --status accepted
gog calendar respond <calendarId> <eventId> --status declined
gog calendar respond <calendarId> <eventId> --status tentative
# Availability
gog calendar freebusy --calendars "primary,work@example.com" \
--from 2025-01-15T00:00:00Z \
--to 2025-01-16T00:00:00Z
gog calendar conflicts --calendars "primary,work@example.com" \
--from 2025-01-15T00:00:00Z \
--to 2025-01-22T00:00:00Z
```
### Drive
```bash
# List and search
gog drive ls --max 20
gog drive ls <folderId> --max 20 # List folder contents
gog drive search "invoice" --max 20
gog drive get <fileId> # Get file metadata
gog drive url <fileId> # Print Drive web URL
# Upload and download
gog drive upload ./path/to/file --folder <folderId>
gog drive download <fileId>
# Organize
gog drive mkdir "New Folder"
gog drive mkdir "New Folder" --parent <parentFolderId>
gog drive rename <fileId> "New Name"
gog drive move <fileId> --folder <destinationFolderId>
gog drive delete <fileId> # Move to trash
# Permissions
gog drive permissions <fileId>
gog drive share <fileId> --email user@example.com --role reader
gog drive share <fileId> --email user@example.com --role writer
gog drive unshare <fileId> --permission-id <permissionId>
```
### Contacts
```bash
# Personal contacts
gog contacts list --max 50
gog contacts search "Ada" --max 50
gog contacts get people/<resourceName>
gog contacts get user@example.com # Get by email
# Other contacts (people you've interacted with)
gog contacts other list --max 50
gog contacts other search "John" --max 50
# Create and update
gog contacts create \
--given-name "John" \
--family-name "Doe" \
--email "john@example.com" \
--phone "+1234567890"
gog contacts update people/<resourceName> \
--given-name "Jane" \
--email "jane@example.com"
gog contacts delete people/<resourceName>
# Workspace directory (requires Google Workspace)
gog contacts directory list --max 50
gog contacts directory search "Jane" --max 50
```
### Tasks
```bash
# Task lists
gog tasks lists --max 50
gog tasks lists create <title>
# Tasks in a list
gog tasks list <tasklistId> --max 50
gog tasks add <tasklistId> --title "Task title"
gog tasks update <tasklistId> <taskId> --title "New title"
gog tasks done <tasklistId> <taskId>
gog tasks undo <tasklistId> <taskId>
gog tasks delete <tasklistId> <taskId>
gog tasks clear <tasklistId>
```
### Sheets
```bash
# Read
gog sheets metadata <spreadsheetId>
gog sheets get <spreadsheetId> 'Sheet1!A1:B10'
# Write
gog sheets update <spreadsheetId> 'A1' 'val1|val2,val3|val4'
gog sheets update <spreadsheetId> 'A1' --json '[["a","b"],["c","d"]]'
gog sheets append <spreadsheetId> 'Sheet1!A:C' 'new|row|data'
gog sheets clear <spreadsheetId> 'Sheet1!A1:B10'
# Create
gog sheets create "My New Spreadsheet" --sheets "Sheet1,Sheet2"
```
### People
```bash
# Profile
gog people me
```
## Output Formats
### Text
Human-readable output with colors (default):
```bash
$ gog gmail search 'newer_than:7d' --max 3
THREAD_ID SUBJECT FROM DATE
18f1a2b3c4d5e6f7 Meeting notes alice@example.com 2025-01-10
17e1d2c3b4a5f6e7 Invoice #12345 billing@vendor.com 2025-01-09
16d1c2b3a4e5f6d7 Project update bob@example.com 2025-01-08
```
### JSON
Machine-readable output for scripting and automation:
```bash
$ gog gmail search 'newer_than:7d' --max 3 --json
{
"threads": [
{
"id": "18f1a2b3c4d5e6f7",
"snippet": "Meeting notes from today...",
"messages": [...]
},
...
]
}
```
Data goes to stdout, errors and progress to stderr for clean piping:
```bash
gog --json drive ls --max 5 | jq '.files[] | select(.mimeType=="application/pdf")'
```
Useful pattern:
- `gog --json ... | jq .`
@ -83,79 +427,160 @@ If you use `pnpm`, see the shortcut section for `pnpm -s` (silent) to keep stdou
## Examples
Drive:
### Search recent emails and download attachments
- `gog drive ls --max 20`
- `gog drive ls --parent <folderId> --max 20`
- `gog drive search "invoice" --max 20`
- `gog drive get <fileId>`
- `gog drive download <fileId> [--out PATH]`
- `gog drive upload ./path/to/file --parent <folderId>`
```bash
# Search for emails from the last week
gog gmail search 'newer_than:7d has:attachment' --max 10
Calendar:
# Get thread details and download attachments
gog gmail thread <threadId> --download
```
- `gog calendar calendars`
- `gog calendar events <calendarId> --from 2025-12-08T00:00:00+01:00 --to 2025-12-15T00:00:00+01:00 --max 250`
- `gog calendar event <calendarId> <eventId>`
- `gog calendar respond <calendarId> <eventId> --status accepted`
### Create a calendar event with attendees
Gmail:
```bash
# Find a free time slot
gog calendar freebusy --calendars "primary" \
--from 2025-01-15T00:00:00Z \
--to 2025-01-16T00:00:00Z
- `gog gmail search 'newer_than:7d' --max 10`
- `gog gmail thread <threadId>`
- `gog gmail get <messageId> --format metadata`
- `gog gmail attachment <messageId> <attachmentId> --out ./attachment.bin`
- `gog gmail labels list`
- `gog gmail labels get INBOX --json` (includes counts)
- `gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" --body-html "<p>Hello</p>"`
- `gog gmail watch start --topic projects/<p>/topics/<t> --label INBOX`
- `gog gmail watch serve --bind 127.0.0.1 --token <shared> --hook-url http://127.0.0.1:18789/hooks/agent`
- `gog gmail history --since <historyId>`
# Create the meeting
gog calendar create primary \
--summary "Team Standup" \
--start 2025-01-15T10:00:00Z \
--end 2025-01-15T10:30:00Z \
--attendees "alice@example.com,bob@example.com"
```
Gmail watch (Pub/Sub push):
### Find and download files from Drive
- Create Pub/Sub topic + push subscription (OIDC preferred; shared token ok for dev).
- `gog gmail watch start --topic projects/<p>/topics/<t> --label INBOX`
- `gog gmail watch serve --bind 0.0.0.0 --verify-oidc --oidc-email <svc@...> --hook-url <url>`
- Full flow + payload details: `docs/watch.md`.
```bash
# Search for PDFs
gog drive search "invoice filetype:pdf" --max 20 --json | \
jq -r '.files[] | .id' | \
while read fileId; do
gog drive download "$fileId"
done
```
Contacts:
### Manage multiple accounts
- `gog contacts list --max 50`
- `gog contacts search "Ada" --max 50`
- `gog contacts get people/...`
- `gog contacts other list --max 50`
```bash
# Check personal Gmail
gog gmail search 'is:unread' --account personal@gmail.com
Tasks:
# Check work Gmail
gog gmail search 'is:unread' --account work@company.com
- `gog tasks lists --max 50`
- `gog tasks lists create <title>`
- `gog tasks list <tasklistId> --max 50`
- `gog tasks add <tasklistId> --title "Task title"`
- `gog tasks update <tasklistId> <taskId> --title "New title"`
- `gog tasks done <tasklistId> <taskId>`
- `gog tasks undo <tasklistId> <taskId>`
- `gog tasks delete <tasklistId> <taskId>`
- `gog tasks clear <tasklistId>`
# Or set default
export GOG_ACCOUNT=work@company.com
gog gmail search 'is:unread'
```
Workspace directory (requires Google Workspace account; `@gmail.com` wont work):
### Update a Google Sheet from a CSV
- `gog contacts directory list --max 50`
- `gog contacts directory search "Jane" --max 50`
```bash
# Convert CSV to pipe-delimited format and update sheet
cat data.csv | tr ',' '|' | \
gog sheets update <spreadsheetId> 'Sheet1!A1'
```
People:
### Batch process Gmail threads
- `gog people me`
```bash
# Mark all emails from a sender as read
gog gmail batch mark-read --query 'from:noreply@example.com'
## Environment
# Archive old emails
gog gmail batch archive --query 'older_than:1y'
- `GOG_ACCOUNT=you@gmail.com` (used if `--account` is omitted)
- `GOG_COLOR=auto|always|never` (default `auto`)
- `GOG_JSON=1` (default JSON output)
- `GOG_PLAIN=1` (default plain output)
# Label important emails
gog gmail batch label --query 'from:boss@example.com' --add-labels IMPORTANT
```
## Advanced Features
### Debug Mode
Enable verbose output for troubleshooting:
```bash
gog --debug gmail search 'newer_than:7d'
# Shows API requests and responses
```
## Global Flags
All commands support these flags:
- `--account <email>` - Account to use (overrides GOG_ACCOUNT)
- `--json` - Output JSON to stdout (best for scripting)
- `--plain` - Output stable, parseable text to stdout (TSV; no colors)
- `--color <mode>` - Color mode: `auto`, `always`, or `never` (default: auto)
- `--force` - Skip confirmations for destructive commands
- `--no-input` - Never prompt; fail instead (useful for CI)
- `--debug` - Enable debug logging
- `--help` - Show help for any command
## Shell Completions
Generate shell completions for your preferred shell:
### Bash
```bash
# macOS (with Homebrew)
gog completion bash > $(brew --prefix)/etc/bash_completion.d/gog
# Linux
gog completion bash > /etc/bash_completion.d/gog
# Or load directly in your current session
source <(gog completion bash)
```
### Zsh
```zsh
# Generate completion file
gog completion zsh > "${fpath[1]}/_gog"
# Or add to .zshrc for automatic loading
echo 'eval "$(gog completion zsh)"' >> ~/.zshrc
# Enable completions if not already enabled
echo "autoload -U compinit; compinit" >> ~/.zshrc
```
### Fish
```fish
gog completion fish > ~/.config/fish/completions/gog.fish
```
### PowerShell
```powershell
# Load for current session
gog completion powershell | Out-String | Invoke-Expression
# Or add to profile for all sessions
gog completion powershell >> $PROFILE
```
After installing completions, start a new shell session for changes to take effect.
## Development
After cloning, install git hooks:
```bash
make setup
```
This installs [lefthook](https://github.com/evilmartians/lefthook) pre-commit and pre-push hooks for linting and testing.
Pinned tools (installed into `.tools/`):
- Format: `make fmt` (goimports + gofumpt)
@ -164,20 +589,36 @@ Pinned tools (installed into `.tools/`):
CI runs format checks, tests, and lint on push/PR.
### `pnpm gog` shortcut
### pnpm Shortcut
Build + run in one step:
Build and run in one step:
- `pnpm gog auth add you@gmail.com`
```bash
pnpm gog auth add you@gmail.com
```
For clean stdout when scripting:
- `pnpm -s gog --json gmail search "from:me" | jq .`
## License
MIT
## Links
- [GitHub Repository](https://github.com/steipete/gogcli)
- [Gmail API Documentation](https://developers.google.com/gmail/api)
- [Google Calendar API Documentation](https://developers.google.com/calendar)
- [Google Drive API Documentation](https://developers.google.com/drive)
- [Google People API Documentation](https://developers.google.com/people)
- [Google Tasks API Documentation](https://developers.google.com/tasks)
- [Google Sheets API Documentation](https://developers.google.com/sheets)
## Credits
This project is inspired by Mario Zechners original CLIs:
This project is inspired by Mario Zechner's original CLIs:
- [`gmcli`](https://github.com/badlogic/gmcli)
- [`gccli`](https://github.com/badlogic/gccli)
- [`gdcli`](https://github.com/badlogic/gdcli)
- [gmcli](https://github.com/badlogic/gmcli)
- [gccli](https://github.com/badlogic/gccli)
- [gdcli](https://github.com/badlogic/gdcli)

View File

@ -34,6 +34,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newAuthListCmd())
cmd.AddCommand(newAuthRemoveCmd(flags))
cmd.AddCommand(newAuthTokensCmd(flags))
cmd.AddCommand(newAuthManageCmd())
return cmd
}
@ -195,7 +196,7 @@ func newAuthTokensExportCmd() *cobra.Command {
if openErr != nil {
return openErr
}
defer f.Close()
defer func() { _ = f.Close() }()
type export struct {
Email string `json:"email"`
@ -401,7 +402,7 @@ func newAuthAddCmd() *cobra.Command {
cmd.Flags().BoolVar(&manual, "manual", false, "Browserless auth flow (paste redirect URL)")
cmd.Flags().BoolVar(&forceConsent, "force-consent", false, "Force consent screen to obtain a refresh token")
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts,tasks,people")
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts,tasks,sheets,people")
return cmd
}
@ -493,3 +494,47 @@ func newAuthRemoveCmd(flags *rootFlags) *cobra.Command {
},
}
}
func newAuthManageCmd() *cobra.Command {
var forceConsent bool
var servicesCSV string
var timeout time.Duration
cmd := &cobra.Command{
Use: "manage",
Short: "Open accounts manager in browser",
Long: "Opens a browser-based UI to manage Google accounts, add new accounts, set defaults, and remove accounts.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var services []googleauth.Service
if strings.EqualFold(strings.TrimSpace(servicesCSV), "") || strings.EqualFold(strings.TrimSpace(servicesCSV), "all") {
services = googleauth.AllServices()
} else {
parts := strings.Split(servicesCSV, ",")
seen := make(map[googleauth.Service]struct{})
for _, p := range parts {
svc, err := googleauth.ParseService(p)
if err != nil {
return err
}
if _, ok := seen[svc]; ok {
continue
}
seen[svc] = struct{}{}
services = append(services, svc)
}
}
return googleauth.StartManageServer(cmd.Context(), googleauth.ManageServerOptions{
Timeout: timeout,
Services: services,
ForceConsent: forceConsent,
})
},
}
cmd.Flags().BoolVar(&forceConsent, "force-consent", false, "Force consent screen when adding accounts")
cmd.Flags().StringVar(&servicesCSV, "services", "all", "Services to authorize: all or comma-separated gmail,calendar,drive,contacts,tasks,sheets,people")
cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Minute, "Server timeout duration")
return cmd
}

View File

@ -79,6 +79,14 @@ func (s *memSecretsStore) ListTokens() ([]secrets.Token, error) {
return out, nil
}
func (s *memSecretsStore) GetDefaultAccount() (string, error) {
return "", nil
}
func (s *memSecretsStore) SetDefaultAccount(email string) error {
return nil
}
func TestAuthTokens_ExportImportRoundtrip_JSON(t *testing.T) {
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })

View File

@ -31,6 +31,10 @@ func newCalendarCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newCalendarDeleteCmd(flags))
cmd.AddCommand(newCalendarFreeBusyCmd(flags))
cmd.AddCommand(newCalendarRespondCmd(flags))
cmd.AddCommand(newCalendarColorsCmd(flags))
cmd.AddCommand(newCalendarConflictsCmd(flags))
cmd.AddCommand(newCalendarSearchCmd(flags))
cmd.AddCommand(newCalendarTimeCmd(flags))
return cmd
}
@ -167,18 +171,25 @@ func newCalendarEventsCmd(flags *rootFlags) *cobra.Command {
var max int64
var page string
var query string
var all bool
cmd := &cobra.Command{
Use: "events <calendarId>",
Short: "List events from a calendar",
Args: cobra.ExactArgs(1),
Use: "events [<calendarId>]",
Short: "List events from a calendar or all calendars",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := args[0]
// Validate args
if !all && len(args) == 0 {
return usage("calendarId required unless --all is specified")
}
if all && len(args) > 0 {
return usage("calendarId not allowed with --all flag")
}
now := time.Now().UTC()
oneWeekLater := now.Add(7 * 24 * time.Hour)
@ -194,50 +205,11 @@ func newCalendarEventsCmd(flags *rootFlags) *cobra.Command {
return err
}
call := svc.Events.List(calendarID).
TimeMin(from).
TimeMax(to).
MaxResults(max).
PageToken(page).
SingleEvents(true).
OrderBy("startTime")
if strings.TrimSpace(query) != "" {
call = call.Q(query)
if all {
return listAllCalendarsEvents(cmd, svc, from, to, max, page, query)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"events": resp.Items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Items) == 0 {
u.Err().Println("No events")
return nil
}
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tSTART\tEND\tSUMMARY")
for _, e := range resp.Items {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Id, eventStart(e), eventEnd(e), e.Summary)
}
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
calendarID := args[0]
return listCalendarEvents(cmd, svc, calendarID, from, to, max, page, query)
},
}
@ -246,9 +218,170 @@ func newCalendarEventsCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().Int64Var(&max, "max", 10, "Max results")
cmd.Flags().StringVar(&page, "page", "", "Page token")
cmd.Flags().StringVar(&query, "query", "", "Free text search")
cmd.Flags().BoolVar(&all, "all", false, "Fetch events from all calendars")
return cmd
}
func listCalendarEvents(cmd *cobra.Command, svc *calendar.Service, calendarID, from, to string, max int64, page, query string) error {
u := ui.FromContext(cmd.Context())
call := svc.Events.List(calendarID).
TimeMin(from).
TimeMax(to).
MaxResults(max).
PageToken(page).
SingleEvents(true).
OrderBy("startTime")
if strings.TrimSpace(query) != "" {
call = call.Q(query)
}
resp, err := call.Context(cmd.Context()).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"events": resp.Items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Items) == 0 {
u.Err().Println("No events")
return nil
}
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "ID\tSTART\tEND\tSUMMARY")
for _, e := range resp.Items {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Id, eventStart(e), eventEnd(e), e.Summary)
}
if tw != nil {
_ = tw.Flush()
}
if resp.NextPageToken != "" {
u.Err().Printf("# Next page: --page %s", resp.NextPageToken)
}
return nil
}
type eventWithCalendar struct {
*calendar.Event
CalendarID string
}
func listAllCalendarsEvents(cmd *cobra.Command, svc *calendar.Service, from, to string, max int64, page, query string) error {
u := ui.FromContext(cmd.Context())
// Get all calendars
calResp, err := svc.CalendarList.List().Context(cmd.Context()).Do()
if err != nil {
return err
}
if len(calResp.Items) == 0 {
u.Err().Println("No calendars")
return nil
}
// Collect events from all calendars
var allEvents []*eventWithCalendar
for _, cal := range calResp.Items {
call := svc.Events.List(cal.Id).
TimeMin(from).
TimeMax(to).
MaxResults(max).
PageToken(page).
SingleEvents(true).
OrderBy("startTime")
if strings.TrimSpace(query) != "" {
call = call.Q(query)
}
resp, err := call.Context(cmd.Context()).Do()
if err != nil {
// Skip calendars that fail (e.g., due to permissions)
continue
}
for _, e := range resp.Items {
allEvents = append(allEvents, &eventWithCalendar{
Event: e,
CalendarID: cal.Id,
})
}
}
if len(allEvents) == 0 {
u.Err().Println("No events")
return nil
}
// Sort events by start time
sortEventsByStartTime(allEvents)
if outfmt.IsJSON(cmd.Context()) {
events := make([]map[string]any, 0, len(allEvents))
for _, e := range allEvents {
eventMap := map[string]any{
"id": e.Id,
"calendarId": e.CalendarID,
"summary": e.Summary,
"start": e.Start,
"end": e.End,
"status": e.Status,
}
if e.Description != "" {
eventMap["description"] = e.Description
}
if e.Location != "" {
eventMap["location"] = e.Location
}
if len(e.Attendees) > 0 {
eventMap["attendees"] = e.Attendees
}
events = append(events, eventMap)
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"events": events})
}
var w io.Writer = os.Stdout
var tw *tabwriter.Writer
if !outfmt.IsPlain(cmd.Context()) {
tw = tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
w = tw
}
fmt.Fprintln(w, "CALENDAR\tID\tSTART\tEND\tSUMMARY")
for _, e := range allEvents {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.CalendarID, e.Id, eventStart(e.Event), eventEnd(e.Event), e.Summary)
}
if tw != nil {
_ = tw.Flush()
}
return nil
}
func sortEventsByStartTime(events []*eventWithCalendar) {
// Simple insertion sort since we expect relatively small lists
for i := 1; i < len(events); i++ {
key := events[i]
keyStart := eventStart(key.Event)
j := i - 1
for j >= 0 && eventStart(events[j].Event) > keyStart {
events[j+1] = events[j]
j--
}
events[j+1] = key
}
}
func newCalendarEventCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "event <calendarId> <eventId>",

View File

@ -0,0 +1,104 @@
package cmd
import (
"fmt"
"os"
"sort"
"strconv"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func newCalendarColorsCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "colors",
Short: "List available event and calendar colors",
Long: `List available event and calendar colors with their IDs.
Event colors can be used when creating or updating events.
Calendar colors can be used when creating or updating calendars.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newCalendarService(cmd.Context(), account)
if err != nil {
return err
}
colors, err := svc.Colors.Get().Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"event": colors.Event,
"calendar": colors.Calendar,
})
}
// Table output
if len(colors.Event) == 0 && len(colors.Calendar) == 0 {
u.Err().Println("No colors available")
return nil
}
// Event colors
if len(colors.Event) > 0 {
fmt.Println("EVENT COLORS:")
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tBACKGROUND\tFOREGROUND")
// Sort color IDs numerically
ids := make([]int, 0, len(colors.Event))
for id := range colors.Event {
if num, err := strconv.Atoi(id); err == nil {
ids = append(ids, num)
}
}
sort.Ints(ids)
for _, num := range ids {
id := strconv.Itoa(num)
c := colors.Event[id]
fmt.Fprintf(tw, "%s\t%s\t%s\n", id, c.Background, c.Foreground)
}
_ = tw.Flush()
fmt.Println()
}
// Calendar colors
if len(colors.Calendar) > 0 {
fmt.Println("CALENDAR COLORS:")
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tBACKGROUND\tFOREGROUND")
// Sort color IDs numerically
ids := make([]int, 0, len(colors.Calendar))
for id := range colors.Calendar {
if num, err := strconv.Atoi(id); err == nil {
ids = append(ids, num)
}
}
sort.Ints(ids)
for _, num := range ids {
id := strconv.Itoa(num)
c := colors.Calendar[id]
fmt.Fprintf(tw, "%s\t%s\t%s\n", id, c.Background, c.Foreground)
}
_ = tw.Flush()
}
return nil
},
}
}

View File

@ -0,0 +1,245 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func TestCalendarColorsCmd_JSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/colors") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"event": map[string]any{
"1": map[string]string{
"background": "#a4bdfc",
"foreground": "#1d1d1d",
},
"2": map[string]string{
"background": "#7ae7bf",
"foreground": "#1d1d1d",
},
},
"calendar": map[string]any{
"1": map[string]string{
"background": "#ac725e",
"foreground": "#1d1d1d",
},
"2": map[string]string{
"background": "#d06b64",
"foreground": "#1d1d1d",
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "colors"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Event map[string]struct {
Background string `json:"background"`
Foreground string `json:"foreground"`
} `json:"event"`
Calendar map[string]struct {
Background string `json:"background"`
Foreground string `json:"foreground"`
} `json:"calendar"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
// Verify event colors
if len(parsed.Event) != 2 {
t.Fatalf("expected 2 event colors, got %d", len(parsed.Event))
}
if parsed.Event["1"].Background != "#a4bdfc" {
t.Errorf("unexpected event color 1 background: %q", parsed.Event["1"].Background)
}
if parsed.Event["1"].Foreground != "#1d1d1d" {
t.Errorf("unexpected event color 1 foreground: %q", parsed.Event["1"].Foreground)
}
// Verify calendar colors
if len(parsed.Calendar) != 2 {
t.Fatalf("expected 2 calendar colors, got %d", len(parsed.Calendar))
}
if parsed.Calendar["1"].Background != "#ac725e" {
t.Errorf("unexpected calendar color 1 background: %q", parsed.Calendar["1"].Background)
}
if parsed.Calendar["1"].Foreground != "#1d1d1d" {
t.Errorf("unexpected calendar color 1 foreground: %q", parsed.Calendar["1"].Foreground)
}
}
func TestCalendarColorsCmd_TableOutput(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/colors") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"event": map[string]any{
"1": map[string]string{
"background": "#a4bdfc",
"foreground": "#1d1d1d",
},
"2": map[string]string{
"background": "#7ae7bf",
"foreground": "#1d1d1d",
},
},
"calendar": map[string]any{
"1": map[string]string{
"background": "#ac725e",
"foreground": "#1d1d1d",
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "calendar", "colors"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
// Verify table headers and content
if !strings.Contains(out, "EVENT COLORS:") {
t.Errorf("output missing event colors header: %q", out)
}
if !strings.Contains(out, "CALENDAR COLORS:") {
t.Errorf("output missing calendar colors header: %q", out)
}
if !strings.Contains(out, "ID") {
t.Errorf("output missing ID column header: %q", out)
}
if !strings.Contains(out, "BACKGROUND") {
t.Errorf("output missing BACKGROUND column header: %q", out)
}
if !strings.Contains(out, "FOREGROUND") {
t.Errorf("output missing FOREGROUND column header: %q", out)
}
// Verify color values appear in output
if !strings.Contains(out, "#a4bdfc") {
t.Errorf("output missing event color background: %q", out)
}
if !strings.Contains(out, "#ac725e") {
t.Errorf("output missing calendar color background: %q", out)
}
if !strings.Contains(out, "#1d1d1d") {
t.Errorf("output missing foreground color: %q", out)
}
}
func TestCalendarColorsCmd_EmptyColors(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/colors") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"event": map[string]any{},
"calendar": map[string]any{},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
// Test JSON output with empty colors
outJSON := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "colors"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Event map[string]any `json:"event"`
Calendar map[string]any `json:"calendar"`
}
if err := json.Unmarshal([]byte(outJSON), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, outJSON)
}
if len(parsed.Event) != 0 {
t.Errorf("expected empty event colors, got %d", len(parsed.Event))
}
if len(parsed.Calendar) != 0 {
t.Errorf("expected empty calendar colors, got %d", len(parsed.Calendar))
}
// Test table output with empty colors
stderr := captureStderr(t, func() {
_ = captureStdout(t, func() {
if err := Execute([]string{"--account", "a@b.com", "calendar", "colors"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(stderr, "No colors available") {
t.Errorf("expected 'No colors available' message, got: %q", stderr)
}
}

View File

@ -0,0 +1,194 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/calendar/v3"
)
type conflict struct {
Start string `json:"start"`
End string `json:"end"`
Calendars []string `json:"calendars"`
}
func newCalendarConflictsCmd(flags *rootFlags) *cobra.Command {
var from string
var to string
var calendars string
cmd := &cobra.Command{
Use: "conflicts",
Short: "Detect overlapping/conflicting events across calendars",
Long: `Detect overlapping busy periods across multiple calendars.
A conflict occurs when the same time slot has busy periods in 2+ calendars.
Uses the FreeBusy API to check calendar availability.
Default time range is now to +7 days.
Default calendars: "primary"`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
// Parse time range
now := time.Now().UTC()
sevenDaysLater := now.Add(7 * 24 * time.Hour)
if strings.TrimSpace(from) == "" {
from = now.Format(time.RFC3339)
}
if strings.TrimSpace(to) == "" {
to = sevenDaysLater.Format(time.RFC3339)
}
// Parse calendar IDs
calendarIDs := splitCSV(calendars)
if len(calendarIDs) == 0 {
return errors.New("no calendar IDs provided")
}
svc, err := newCalendarService(cmd.Context(), account)
if err != nil {
return err
}
// Build FreeBusy request
items := make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs))
for _, id := range calendarIDs {
items = append(items, &calendar.FreeBusyRequestItem{Id: id})
}
resp, err := svc.Freebusy.Query(&calendar.FreeBusyRequest{
TimeMin: from,
TimeMax: to,
Items: items,
}).Do()
if err != nil {
return err
}
// Detect conflicts
conflicts := detectConflicts(resp.Calendars)
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"conflicts": conflicts,
"count": len(conflicts),
})
}
// Table output
if len(conflicts) == 0 {
u.Out().Println("No conflicts found")
return nil
}
fmt.Printf("CONFLICTS FOUND: %d\n\n", len(conflicts))
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "START\tEND\tCALENDARS")
for _, c := range conflicts {
fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Start, c.End, strings.Join(c.Calendars, ", "))
}
_ = tw.Flush()
return nil
},
}
cmd.Flags().StringVar(&from, "from", "", "Start time (RFC3339; default: now)")
cmd.Flags().StringVar(&to, "to", "", "End time (RFC3339; default: +7d)")
cmd.Flags().StringVar(&calendars, "calendars", "primary", "Comma-separated calendar IDs")
return cmd
}
// detectConflicts finds overlapping busy periods across calendars
func detectConflicts(calendars map[string]calendar.FreeBusyCalendar) []conflict {
if len(calendars) < 2 {
// Need at least 2 calendars to have conflicts
return []conflict{}
}
// Collect all busy periods with their calendar IDs
type busyPeriod struct {
start time.Time
end time.Time
calendarID string
}
var allBusy []busyPeriod
for calID, cal := range calendars {
for _, b := range cal.Busy {
start, err := time.Parse(time.RFC3339, b.Start)
if err != nil {
continue
}
end, err := time.Parse(time.RFC3339, b.End)
if err != nil {
continue
}
allBusy = append(allBusy, busyPeriod{
start: start,
end: end,
calendarID: calID,
})
}
}
// Find overlapping periods
var conflicts []conflict
seen := make(map[string]bool)
for i := 0; i < len(allBusy); i++ {
for j := i + 1; j < len(allBusy); j++ {
a := allBusy[i]
b := allBusy[j]
// Skip if same calendar
if a.calendarID == b.calendarID {
continue
}
// Check if they overlap: a.start < b.end AND a.end > b.start
if a.start.Before(b.end) && a.end.After(b.start) {
// Calculate overlap period
overlapStart := a.start
if b.start.After(a.start) {
overlapStart = b.start
}
overlapEnd := a.end
if b.end.Before(a.end) {
overlapEnd = b.end
}
// Create conflict key to avoid duplicates
calendarsInvolved := []string{a.calendarID, b.calendarID}
if a.calendarID > b.calendarID {
calendarsInvolved = []string{b.calendarID, a.calendarID}
}
key := fmt.Sprintf("%s|%s|%s", overlapStart.Format(time.RFC3339), overlapEnd.Format(time.RFC3339), strings.Join(calendarsInvolved, ","))
if !seen[key] {
seen[key] = true
conflicts = append(conflicts, conflict{
Start: overlapStart.Format(time.RFC3339),
End: overlapEnd.Format(time.RFC3339),
Calendars: calendarsInvolved,
})
}
}
}
}
return conflicts
}

View File

@ -0,0 +1,397 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func TestCalendarConflictsCmd_WithConflicts_JSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/freeBusy") && r.Method == http.MethodPost {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"calendars": map[string]any{
"primary": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:00:00Z",
"end": "2024-12-13T11:00:00Z",
},
},
},
"work@example.com": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:30:00Z",
"end": "2024-12-13T11:30:00Z",
},
},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"--account", "a@b.com",
"calendar", "conflicts",
"--from", "2024-12-13T09:00:00Z",
"--to", "2024-12-13T12:00:00Z",
"--calendars", "primary,work@example.com",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Conflicts []struct {
Start string `json:"start"`
End string `json:"end"`
Calendars []string `json:"calendars"`
} `json:"conflicts"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Count != 1 {
t.Errorf("expected count 1, got %d", parsed.Count)
}
if len(parsed.Conflicts) != 1 {
t.Fatalf("expected 1 conflict, got %d", len(parsed.Conflicts))
}
// Overlap is from 10:30 to 11:00
if parsed.Conflicts[0].Start != "2024-12-13T10:30:00Z" {
t.Errorf("unexpected conflict start: %q", parsed.Conflicts[0].Start)
}
if parsed.Conflicts[0].End != "2024-12-13T11:00:00Z" {
t.Errorf("unexpected conflict end: %q", parsed.Conflicts[0].End)
}
if len(parsed.Conflicts[0].Calendars) != 2 {
t.Fatalf("expected 2 calendars in conflict, got %d", len(parsed.Conflicts[0].Calendars))
}
}
func TestCalendarConflictsCmd_NoConflicts_JSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/freeBusy") && r.Method == http.MethodPost {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"calendars": map[string]any{
"primary": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:00:00Z",
"end": "2024-12-13T11:00:00Z",
},
},
},
"work@example.com": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T12:00:00Z",
"end": "2024-12-13T13:00:00Z",
},
},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"--account", "a@b.com",
"calendar", "conflicts",
"--from", "2024-12-13T09:00:00Z",
"--to", "2024-12-13T14:00:00Z",
"--calendars", "primary,work@example.com",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Conflicts []map[string]any `json:"conflicts"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Count != 0 {
t.Errorf("expected count 0, got %d", parsed.Count)
}
if len(parsed.Conflicts) != 0 {
t.Errorf("expected 0 conflicts, got %d", len(parsed.Conflicts))
}
}
func TestCalendarConflictsCmd_TableOutput(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/freeBusy") && r.Method == http.MethodPost {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"calendars": map[string]any{
"primary": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:00:00Z",
"end": "2024-12-13T11:00:00Z",
},
},
},
"work@example.com": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:30:00Z",
"end": "2024-12-13T11:30:00Z",
},
},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--account", "a@b.com",
"calendar", "conflicts",
"--from", "2024-12-13T09:00:00Z",
"--to", "2024-12-13T12:00:00Z",
"--calendars", "primary,work@example.com",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
// Verify table output contains expected elements
if !strings.Contains(out, "CONFLICTS FOUND: 1") {
t.Errorf("output missing conflict count: %q", out)
}
if !strings.Contains(out, "2024-12-13T10:30:00Z") {
t.Errorf("output missing conflict start time: %q", out)
}
if !strings.Contains(out, "2024-12-13T11:00:00Z") {
t.Errorf("output missing conflict end time: %q", out)
}
if !strings.Contains(out, "primary") || !strings.Contains(out, "work@example.com") {
t.Errorf("output missing calendar IDs: %q", out)
}
if !strings.Contains(out, "START") || !strings.Contains(out, "END") || !strings.Contains(out, "CALENDARS") {
t.Errorf("output missing table headers: %q", out)
}
}
func TestCalendarConflictsCmd_MultiCalendar(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/freeBusy") && r.Method == http.MethodPost {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"calendars": map[string]any{
"primary": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:00:00Z",
"end": "2024-12-13T11:00:00Z",
},
},
},
"work@example.com": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:30:00Z",
"end": "2024-12-13T11:30:00Z",
},
},
},
"personal@example.com": map[string]any{
"busy": []map[string]any{
{
"start": "2024-12-13T10:45:00Z",
"end": "2024-12-13T11:15:00Z",
},
},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"--account", "a@b.com",
"calendar", "conflicts",
"--from", "2024-12-13T09:00:00Z",
"--to", "2024-12-13T12:00:00Z",
"--calendars", "primary,work@example.com,personal@example.com",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Conflicts []struct {
Start string `json:"start"`
End string `json:"end"`
Calendars []string `json:"calendars"`
} `json:"conflicts"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
// Should have 3 conflicts:
// 1. primary vs work (10:30-11:00)
// 2. primary vs personal (10:45-11:00)
// 3. work vs personal (10:45-11:15)
if parsed.Count != 3 {
t.Errorf("expected count 3, got %d", parsed.Count)
}
if len(parsed.Conflicts) != 3 {
t.Fatalf("expected 3 conflicts, got %d", len(parsed.Conflicts))
}
// Verify all conflicts have exactly 2 calendars involved
for i, c := range parsed.Conflicts {
if len(c.Calendars) != 2 {
t.Errorf("conflict %d: expected 2 calendars, got %d", i, len(c.Calendars))
}
}
}
func TestCalendarConflictsCmd_NoConflicts_TableOutput(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/freeBusy") && r.Method == http.MethodPost {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"calendars": map[string]any{
"primary": map[string]any{
"busy": []map[string]any{},
},
"work@example.com": map[string]any{
"busy": []map[string]any{},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--account", "a@b.com",
"calendar", "conflicts",
"--from", "2024-12-13T09:00:00Z",
"--to", "2024-12-13T14:00:00Z",
"--calendars", "primary,work@example.com",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
if !strings.Contains(out, "No conflicts found") {
t.Errorf("expected 'No conflicts found' message, got: %q", out)
}
}

View File

@ -13,12 +13,21 @@ import (
func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
var status string
var sendUpdates string
var comment string
cmd := &cobra.Command{
Use: "respond <calendarId> <eventId>",
Short: "Respond to a meeting invitation (accept/decline/tentative)",
Args: cobra.ExactArgs(2),
Short: "Respond to a calendar event invitation",
Long: `Respond to a calendar event invitation with accepted, declined, tentative, or needsAction.
Status values:
- accepted: Accept the invitation
- declined: Decline the invitation
- tentative: Mark as tentative (maybe)
- needsAction: Reset to needs action
You can optionally include a comment with your response.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
@ -28,18 +37,18 @@ func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
calendarID := args[0]
eventID := args[1]
// Validate status
status = strings.TrimSpace(status)
switch status {
case "accepted", "declined", "tentative":
default:
return fmt.Errorf("invalid --status: %q (expected accepted|declined|tentative)", status)
validStatuses := []string{"accepted", "declined", "tentative", "needsAction"}
isValid := false
for _, v := range validStatuses {
if status == v {
isValid = true
break
}
}
sendUpdates = strings.TrimSpace(sendUpdates)
switch sendUpdates {
case "all", "none", "externalOnly":
default:
return fmt.Errorf("invalid --send-updates: %q (expected all|none|externalOnly)", sendUpdates)
if !isValid {
return fmt.Errorf("invalid status %q; must be one of: %s", status, strings.Join(validStatuses, ", "))
}
svc, err := newCalendarService(cmd.Context(), account)
@ -47,33 +56,42 @@ func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
return err
}
e, err := svc.Events.Get(calendarID, eventID).Do()
// Get the event
event, err := svc.Events.Get(calendarID, eventID).Do()
if err != nil {
return err
}
if e == nil || len(e.Attendees) == 0 {
// Find the authenticated user in attendees
if len(event.Attendees) == 0 {
return errors.New("event has no attendees")
}
updatedAny := false
for _, a := range e.Attendees {
if a == nil {
continue
var selfAttendee *int
for i, a := range event.Attendees {
if a.Self {
selfAttendee = &i
break
}
if a.Self || strings.EqualFold(a.Email, account) {
a.ResponseStatus = status
updatedAny = true
}
}
if !updatedAny {
return errors.New("no attendee matches the authenticated user")
}
call := svc.Events.Update(calendarID, eventID, e)
if sendUpdates != "none" {
call = call.SendUpdates(sendUpdates)
if selfAttendee == nil {
return errors.New("you are not an attendee of this event")
}
updated, err := call.Do()
// Check if user is the organizer
if event.Attendees[*selfAttendee].Organizer {
return errors.New("cannot respond to your own event (you are the organizer)")
}
// Update the attendee's response status
event.Attendees[*selfAttendee].ResponseStatus = status
if strings.TrimSpace(comment) != "" {
event.Attendees[*selfAttendee].Comment = comment
}
// Patch the event with updated attendees
updated, err := svc.Events.Patch(calendarID, eventID, event).Do()
if err != nil {
return err
}
@ -83,8 +101,11 @@ func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("status\t%s", status)
u.Out().Printf("send_updates\t%s", sendUpdates)
u.Out().Printf("summary\t%s", orEmpty(updated.Summary, "(no title)"))
u.Out().Printf("response_status\t%s", status)
if strings.TrimSpace(comment) != "" {
u.Out().Printf("comment\t%s", comment)
}
if updated.HtmlLink != "" {
u.Out().Printf("link\t%s", updated.HtmlLink)
}
@ -92,8 +113,9 @@ func newCalendarRespondCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&status, "status", "", "Response status: accepted|declined|tentative (required)")
cmd.Flags().StringVar(&status, "status", "", "Response status (accepted, declined, tentative, needsAction) (required)")
cmd.Flags().StringVar(&comment, "comment", "", "Optional comment/note to include with response")
_ = cmd.MarkFlagRequired("status")
cmd.Flags().StringVar(&sendUpdates, "send-updates", "none", "Send updates: all|none|externalOnly (default: none)")
return cmd
}

View File

@ -0,0 +1,98 @@
package cmd
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func newCalendarSearchCmd(flags *rootFlags) *cobra.Command {
var from string
var to string
var calendarID string
var max int64
cmd := &cobra.Command{
Use: "search <query>",
Short: "Search for events by text query across calendars",
Long: `Search for calendar events matching a text query.
The query searches across event titles, descriptions, locations, and attendees.
Default time range is 30 days ago to 90 days from now.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
query := strings.TrimSpace(args[0])
if query == "" {
return fmt.Errorf("search query cannot be empty")
}
// Calculate default time range if not specified
now := time.Now().UTC()
thirtyDaysAgo := now.Add(-30 * 24 * time.Hour)
ninetyDaysLater := now.Add(90 * 24 * time.Hour)
if strings.TrimSpace(from) == "" {
from = thirtyDaysAgo.Format(time.RFC3339)
}
if strings.TrimSpace(to) == "" {
to = ninetyDaysLater.Format(time.RFC3339)
}
svc, err := newCalendarService(cmd.Context(), account)
if err != nil {
return err
}
call := svc.Events.List(calendarID).
Q(query).
TimeMin(from).
TimeMax(to).
MaxResults(max).
SingleEvents(true).
OrderBy("startTime")
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"events": resp.Items,
"query": query,
})
}
if len(resp.Items) == 0 {
u.Err().Println("No events found")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tSTART\tEND\tSUMMARY")
for _, e := range resp.Items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Id, eventStart(e), eventEnd(e), e.Summary)
}
_ = tw.Flush()
return nil
},
}
cmd.Flags().StringVar(&from, "from", "", "Start time (RFC3339; default: 30 days ago)")
cmd.Flags().StringVar(&to, "to", "", "End time (RFC3339; default: 90 days from now)")
cmd.Flags().StringVar(&calendarID, "calendar", "primary", "Calendar ID")
cmd.Flags().Int64Var(&max, "max", 25, "Max results")
return cmd
}

View File

@ -0,0 +1,337 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func TestCalendarSearchCmd_JSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/events") && r.Method == http.MethodGet {
// Verify query parameter is set
q := r.URL.Query().Get("q")
if q != "team meeting" {
t.Errorf("unexpected query: %q", q)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "event1",
"summary": "Team meeting",
"start": map[string]any{"dateTime": "2024-01-15T10:00:00Z"},
"end": map[string]any{"dateTime": "2024-01-15T11:00:00Z"},
},
{
"id": "event2",
"summary": "Team standup meeting",
"start": map[string]any{"dateTime": "2024-01-16T09:00:00Z"},
"end": map[string]any{"dateTime": "2024-01-16T09:30:00Z"},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "search", "team meeting"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Events []struct {
ID string `json:"id"`
Summary string `json:"summary"`
} `json:"events"`
Query string `json:"query"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Query != "team meeting" {
t.Errorf("unexpected query in output: %q", parsed.Query)
}
if len(parsed.Events) != 2 {
t.Fatalf("expected 2 events, got %d", len(parsed.Events))
}
if parsed.Events[0].ID != "event1" {
t.Errorf("unexpected first event ID: %q", parsed.Events[0].ID)
}
if parsed.Events[1].ID != "event2" {
t.Errorf("unexpected second event ID: %q", parsed.Events[1].ID)
}
}
func TestCalendarSearchCmd_NoResults(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/events") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "search", "nonexistent"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
// In JSON mode, should return empty events array
var parsed struct {
Events []map[string]any `json:"events"`
Query string `json:"query"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if len(parsed.Events) != 0 {
t.Errorf("expected 0 events, got %d", len(parsed.Events))
}
if parsed.Query != "nonexistent" {
t.Errorf("unexpected query: %q", parsed.Query)
}
}
func TestCalendarSearchCmd_WithTimeRange(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/events") && r.Method == http.MethodGet {
// Verify time range parameters
timeMin := r.URL.Query().Get("timeMin")
timeMax := r.URL.Query().Get("timeMax")
if timeMin != "2024-01-01T00:00:00Z" {
t.Errorf("unexpected timeMin: %q", timeMin)
}
if timeMax != "2024-01-31T23:59:59Z" {
t.Errorf("unexpected timeMax: %q", timeMax)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "event1",
"summary": "Meeting",
"start": map[string]any{"dateTime": "2024-01-15T10:00:00Z"},
"end": map[string]any{"dateTime": "2024-01-15T11:00:00Z"},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"--account", "a@b.com",
"calendar", "search", "meeting",
"--from", "2024-01-01T00:00:00Z",
"--to", "2024-01-31T23:59:59Z",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Events []struct {
ID string `json:"id"`
} `json:"events"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v", err)
}
if len(parsed.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(parsed.Events))
}
}
func TestCalendarSearchCmd_TableOutput(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/events") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "event1",
"summary": "Team meeting",
"start": map[string]any{"dateTime": "2024-01-15T10:00:00Z"},
"end": map[string]any{"dateTime": "2024-01-15T11:00:00Z"},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "calendar", "search", "team"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
// Verify table output contains expected fields
if !strings.Contains(out, "event1") {
t.Errorf("output missing event id: %q", out)
}
if !strings.Contains(out, "Team meeting") {
t.Errorf("output missing summary: %q", out)
}
if !strings.Contains(out, "2024-01-15T10:00:00Z") {
t.Errorf("output missing start time: %q", out)
}
if !strings.Contains(out, "ID") && !strings.Contains(out, "START") {
t.Errorf("output missing table headers: %q", out)
}
}
func TestCalendarSearchCmd_MaxResults(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/events") && r.Method == http.MethodGet {
// Verify maxResults parameter
maxResults := r.URL.Query().Get("maxResults")
if maxResults != "5" {
t.Errorf("unexpected maxResults: %q", maxResults)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "event1",
"summary": "Meeting 1",
"start": map[string]any{"dateTime": "2024-01-15T10:00:00Z"},
"end": map[string]any{"dateTime": "2024-01-15T11:00:00Z"},
},
{
"id": "event2",
"summary": "Meeting 2",
"start": map[string]any{"dateTime": "2024-01-16T10:00:00Z"},
"end": map[string]any{"dateTime": "2024-01-16T11:00:00Z"},
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{
"--json",
"--account", "a@b.com",
"calendar", "search", "meeting",
"--max", "5",
}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Events []struct {
ID string `json:"id"`
} `json:"events"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v", err)
}
if len(parsed.Events) != 2 {
t.Fatalf("expected 2 events, got %d", len(parsed.Events))
}
}

View File

@ -0,0 +1,88 @@
package cmd
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
func newCalendarTimeCmd(flags *rootFlags) *cobra.Command {
var calendarID string
var timezone string
cmd := &cobra.Command{
Use: "time",
Short: "Show current time in a calendar's timezone",
Long: `Show the current time in a calendar's timezone or a specified timezone.
If --timezone is provided, uses that timezone directly.
Otherwise, retrieves the timezone from the specified calendar (default: "primary").`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
var tz string
var loc *time.Location
if timezone != "" {
// Use provided timezone
tz = timezone
loc, err = time.LoadLocation(timezone)
if err != nil {
return fmt.Errorf("invalid timezone %q: %w", timezone, err)
}
} else {
// Get timezone from calendar
svc, err := newCalendarService(cmd.Context(), account)
if err != nil {
return err
}
cal, err := svc.CalendarList.Get(calendarID).Do()
if err != nil {
return fmt.Errorf("failed to get calendar %q: %w", calendarID, err)
}
tz = cal.TimeZone
if tz == "" {
return fmt.Errorf("calendar %q has no timezone set", calendarID)
}
loc, err = time.LoadLocation(tz)
if err != nil {
return fmt.Errorf("invalid calendar timezone %q: %w", tz, err)
}
}
// Get current time in the timezone
now := time.Now().In(loc)
formatted := now.Format("Monday, January 02, 2006 03:04 PM")
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"timezone": tz,
"current_time": now.Format(time.RFC3339),
"formatted": formatted,
})
}
// Table output
u.Out().Printf("timezone\t%s", tz)
u.Out().Printf("current_time\t%s", now.Format(time.RFC3339))
u.Out().Printf("formatted\t%s", formatted)
return nil
},
}
cmd.Flags().StringVar(&calendarID, "calendar", "primary", "Calendar ID to get timezone from")
cmd.Flags().StringVar(&timezone, "timezone", "", "Override timezone (e.g., America/New_York, UTC)")
return cmd
}

View File

@ -0,0 +1,244 @@
package cmd
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"google.golang.org/api/calendar/v3"
"google.golang.org/api/option"
)
func TestCalendarTimeCmd_JSON(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/users/me/calendarList/primary") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "primary",
"summary": "Primary Calendar",
"timeZone": "America/Los_Angeles",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "time"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Timezone string `json:"timezone"`
CurrentTime string `json:"current_time"`
Formatted string `json:"formatted"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
// Verify timezone
if parsed.Timezone != "America/Los_Angeles" {
t.Errorf("expected timezone America/Los_Angeles, got %q", parsed.Timezone)
}
// Verify current_time is valid RFC3339
if _, err := time.Parse(time.RFC3339, parsed.CurrentTime); err != nil {
t.Errorf("current_time is not valid RFC3339: %v", err)
}
// Verify formatted is not empty
if parsed.Formatted == "" {
t.Error("formatted time is empty")
}
}
func TestCalendarTimeCmd_TableOutput(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/users/me/calendarList/primary") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "primary",
"summary": "Primary Calendar",
"timeZone": "America/New_York",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "calendar", "time"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
// Verify table output contains expected fields
if !strings.Contains(out, "timezone") {
t.Errorf("output missing timezone field: %q", out)
}
if !strings.Contains(out, "current_time") {
t.Errorf("output missing current_time field: %q", out)
}
if !strings.Contains(out, "formatted") {
t.Errorf("output missing formatted field: %q", out)
}
if !strings.Contains(out, "America/New_York") {
t.Errorf("output missing timezone value: %q", out)
}
}
func TestCalendarTimeCmd_WithTimezoneFlag(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
// No server needed since we're using --timezone flag
newCalendarService = func(context.Context, string) (*calendar.Service, error) {
t.Fatal("should not call calendar service when --timezone is provided")
return nil, nil
}
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "time", "--timezone", "UTC"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Timezone string `json:"timezone"`
CurrentTime string `json:"current_time"`
Formatted string `json:"formatted"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
// Verify timezone
if parsed.Timezone != "UTC" {
t.Errorf("expected timezone UTC, got %q", parsed.Timezone)
}
// Verify current_time is valid RFC3339 and ends with Z (UTC)
parsedTime, err := time.Parse(time.RFC3339, parsed.CurrentTime)
if err != nil {
t.Errorf("current_time is not valid RFC3339: %v", err)
}
if parsedTime.Location().String() != "UTC" {
t.Errorf("expected UTC timezone, got %q", parsedTime.Location().String())
}
}
func TestCalendarTimeCmd_InvalidTimezone(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
// No server needed since we're testing error case
newCalendarService = func(context.Context, string) (*calendar.Service, error) {
t.Fatal("should not call calendar service when invalid timezone is provided")
return nil, nil
}
stderr := captureStderr(t, func() {
_ = captureStdout(t, func() {
err := Execute([]string{"--account", "a@b.com", "calendar", "time", "--timezone", "Invalid/Timezone"})
if err == nil {
t.Fatal("expected error for invalid timezone, got nil")
}
})
})
// Verify error message contains timezone information
if !strings.Contains(stderr, "Invalid/Timezone") && !strings.Contains(stderr, "timezone") {
t.Errorf("expected error message about invalid timezone, got: %q", stderr)
}
}
func TestCalendarTimeCmd_CustomCalendar(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/users/me/calendarList/custom-cal-id") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "custom-cal-id",
"summary": "Custom Calendar",
"timeZone": "Europe/London",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := calendar.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--json", "--account", "a@b.com", "calendar", "time", "--calendar", "custom-cal-id"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
var parsed struct {
Timezone string `json:"timezone"`
CurrentTime string `json:"current_time"`
Formatted string `json:"formatted"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
// Verify timezone from custom calendar
if parsed.Timezone != "Europe/London" {
t.Errorf("expected timezone Europe/London, got %q", parsed.Timezone)
}
}

View File

@ -0,0 +1,62 @@
package cmd
import (
"os"
"github.com/spf13/cobra"
)
func newCompletionCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: `Generate shell completion scripts for gog.
To load completions:
Bash:
$ source <(gog completion bash)
# To load completions for each session, execute once:
# Linux:
$ gog completion bash > /etc/bash_completion.d/gog
# macOS:
$ gog completion bash > $(brew --prefix)/etc/bash_completion.d/gog
Zsh:
# If shell completion is not already enabled in your environment,
# you will need to enable it. You can execute the following once:
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
$ gog completion zsh > "${fpath[1]}/_gog"
# You will need to start a new shell for this setup to take effect.
Fish:
$ gog completion fish | source
# To load completions for each session, execute once:
$ gog completion fish > ~/.config/fish/completions/gog.fish
PowerShell:
PS> gog completion powershell | Out-String | Invoke-Expression
# To load completions for every new session, run:
PS> gog completion powershell > gog.ps1
# and source this file from your PowerShell profile.
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
}
return nil
},
}
return cmd
}

View File

@ -82,6 +82,7 @@ func newDriveLsCmd(flags *rootFlags) *cobra.Command {
SupportsAllDrives(true).
IncludeItemsFromAllDrives(true).
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -164,6 +165,7 @@ func newDriveSearchCmd(flags *rootFlags) *cobra.Command {
SupportsAllDrives(true).
IncludeItemsFromAllDrives(true).
Fields("nextPageToken, files(id, name, mimeType, size, modifiedTime, parents, webViewLink)").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -236,6 +238,7 @@ func newDriveGetCmd(flags *rootFlags) *cobra.Command {
f, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType, size, modifiedTime, createdTime, parents, webViewLink, description, starred").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -287,6 +290,7 @@ func newDriveDownloadCmd(flags *rootFlags) *cobra.Command {
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, mimeType").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -296,14 +300,20 @@ func newDriveDownloadCmd(flags *rootFlags) *cobra.Command {
}
destPath := strings.TrimSpace(outPathFlag)
// Sanitize filename to prevent path traversal.
safeName := filepath.Base(meta.Name)
if safeName == "" || safeName == "." || safeName == ".." {
safeName = "download"
}
defaultName := fmt.Sprintf("%s_%s", fileID, safeName)
if destPath == "" {
dir, dirErr := config.EnsureDriveDownloadsDir()
if dirErr != nil {
return dirErr
}
destPath = filepath.Join(dir, fmt.Sprintf("%s_%s", fileID, meta.Name))
destPath = filepath.Join(dir, defaultName)
} else if st, statErr := os.Stat(destPath); statErr == nil && st.IsDir() {
destPath = filepath.Join(destPath, fmt.Sprintf("%s_%s", fileID, meta.Name))
destPath = filepath.Join(destPath, defaultName)
}
downloadedPath, size, err := downloadDriveFile(cmd.Context(), svc, meta, destPath)
@ -371,6 +381,7 @@ func newDriveUploadCmd(flags *rootFlags) *cobra.Command {
SupportsAllDrives(true).
Media(f, gapi.ContentType(mimeType)).
Fields("id, name, mimeType, size, webViewLink").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -426,6 +437,7 @@ func newDriveMkdirCmd(flags *rootFlags) *cobra.Command {
created, err := svc.Files.Create(f).
SupportsAllDrives(true).
Fields("id, name, webViewLink").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -470,7 +482,7 @@ func newDriveDeleteCmd(flags *rootFlags) *cobra.Command {
return err
}
if err := svc.Files.Delete(fileID).SupportsAllDrives(true).Do(); err != nil {
if err := svc.Files.Delete(fileID).SupportsAllDrives(true).Context(cmd.Context()).Do(); err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
@ -513,6 +525,7 @@ func newDriveMoveCmd(flags *rootFlags) *cobra.Command {
meta, err := svc.Files.Get(fileID).
SupportsAllDrives(true).
Fields("id, name, parents").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -526,7 +539,7 @@ func newDriveMoveCmd(flags *rootFlags) *cobra.Command {
call = call.RemoveParents(strings.Join(meta.Parents, ","))
}
updated, err := call.Do()
updated, err := call.Context(cmd.Context()).Do()
if err != nil {
return err
}
@ -567,6 +580,7 @@ func newDriveRenameCmd(flags *rootFlags) *cobra.Command {
updated, err := svc.Files.Update(fileID, &drive.File{Name: newName}).
SupportsAllDrives(true).
Fields("id, name").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -629,12 +643,13 @@ func newDriveShareCmd(flags *rootFlags) *cobra.Command {
SupportsAllDrives(true).
SendNotificationEmail(false).
Fields("id, type, role, emailAddress").
Context(cmd.Context()).
Do()
if err != nil {
return err
}
link, err := driveWebLink(svc, fileID)
link, err := driveWebLink(cmd.Context(), svc, fileID)
if err != nil {
return err
}
@ -683,7 +698,7 @@ func newDriveUnshareCmd(flags *rootFlags) *cobra.Command {
return err
}
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Do(); err != nil {
if err := svc.Permissions.Delete(fileID, permissionID).SupportsAllDrives(true).Context(cmd.Context()).Do(); err != nil {
return err
}
@ -729,6 +744,7 @@ func newDrivePermissionsCmd(flags *rootFlags) *cobra.Command {
PageToken(page).
SupportsAllDrives(true).
Fields("nextPageToken, permissions(id, type, role, emailAddress)").
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -794,7 +810,7 @@ func newDriveURLCmd(flags *rootFlags) *cobra.Command {
}
for _, id := range args {
link, err := driveWebLink(svc, id)
link, err := driveWebLink(cmd.Context(), svc, id)
if err != nil {
return err
}
@ -807,7 +823,7 @@ func newDriveURLCmd(flags *rootFlags) *cobra.Command {
if outfmt.IsJSON(cmd.Context()) {
urls := make([]map[string]string, 0, len(args))
for _, id := range args {
link, err := driveWebLink(svc, id)
link, err := driveWebLink(cmd.Context(), svc, id)
if err != nil {
return err
}
@ -840,7 +856,10 @@ func buildDriveSearchQuery(text string) string {
}
func escapeDriveQueryString(s string) string {
return strings.ReplaceAll(s, "'", "\\'")
// Escape backslashes first, then single quotes
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, "'", "\\'")
return s
}
func driveType(mimeType string) string {
@ -1005,8 +1024,8 @@ func driveExportExtension(mimeType string) string {
}
}
func driveWebLink(svc *drive.Service, fileID string) (string, error) {
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Do()
func driveWebLink(ctx context.Context, svc *drive.Service, fileID string) (string, error) {
f, err := svc.Files.Get(fileID).SupportsAllDrives(true).Fields("webViewLink").Context(ctx).Do()
if err != nil {
return "", err
}

View File

@ -35,7 +35,7 @@ func TestExecute_CalendarRespond_JSON(t *testing.T) {
},
})
return
case http.MethodPut:
case http.MethodPatch:
if got := r.URL.Query().Get("sendUpdates"); got != "" {
t.Fatalf("expected no sendUpdates by default, got %q", got)
}

View File

@ -1,11 +1,13 @@
package cmd
import (
"context"
"fmt"
"io"
"net/mail"
"os"
"strings"
"sync"
"text/tabwriter"
"time"
@ -33,6 +35,13 @@ func newGmailCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newGmailDraftsCmd(flags))
cmd.AddCommand(newGmailWatchCmd(flags))
cmd.AddCommand(newGmailHistoryCmd(flags))
cmd.AddCommand(newGmailAutoForwardCmd(flags))
cmd.AddCommand(newGmailBatchCmd(flags))
cmd.AddCommand(newGmailDelegatesCmd(flags))
cmd.AddCommand(newGmailFiltersCmd(flags))
cmd.AddCommand(newGmailForwardingCmd(flags))
cmd.AddCommand(newGmailSendAsCmd(flags))
cmd.AddCommand(newGmailVacationCmd(flags))
return cmd
}
@ -61,6 +70,7 @@ func newGmailSearchCmd(flags *rootFlags) *cobra.Command {
Q(query).
MaxResults(max).
PageToken(page).
Context(cmd.Context()).
Do()
if err != nil {
return err
@ -71,55 +81,10 @@ func newGmailSearchCmd(flags *rootFlags) *cobra.Command {
return err
}
type item struct {
ID string `json:"id"`
Date string `json:"date,omitempty"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Labels []string `json:"labels,omitempty"`
}
items := make([]item, 0, len(resp.Threads))
for _, t := range resp.Threads {
if t.Id == "" {
continue
}
thread, err := svc.Users.Threads.Get("me", t.Id).
Format("metadata").
MetadataHeaders("From", "Subject", "Date").
Do()
if err != nil {
return err
}
msg := firstMessage(thread)
date := ""
from := ""
subject := ""
var labels []string
if msg != nil {
date = formatGmailDate(headerValue(msg.Payload, "Date"))
from = headerValue(msg.Payload, "From")
subject = headerValue(msg.Payload, "Subject")
if len(msg.LabelIds) > 0 {
names := make([]string, 0, len(msg.LabelIds))
for _, id := range msg.LabelIds {
if n, ok := idToName[id]; ok {
names = append(names, n)
} else {
names = append(names, id)
}
}
labels = names
}
}
items = append(items, item{
ID: t.Id,
Date: date,
From: sanitizeTab(from),
Subject: sanitizeTab(subject),
Labels: labels,
})
// Fetch thread details concurrently (fixes N+1 query pattern)
items, err := fetchThreadDetails(cmd.Context(), svc, resp.Threads, idToName)
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
@ -199,3 +164,109 @@ func mailParseDate(s string) (time.Time, error) {
// net/mail has the most compatible Date parser, but we keep this isolated for easier tests/mocks later.
return mail.ParseDate(s)
}
// threadItem holds parsed thread metadata for display/JSON output
type threadItem struct {
ID string `json:"id"`
Date string `json:"date,omitempty"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Labels []string `json:"labels,omitempty"`
}
// fetchThreadDetails fetches thread metadata concurrently with bounded parallelism.
// This eliminates N+1 queries by fetching all threads in parallel.
func fetchThreadDetails(ctx context.Context, svc *gmail.Service, threads []*gmail.Thread, idToName map[string]string) ([]threadItem, error) {
if len(threads) == 0 {
return nil, nil
}
const maxConcurrency = 10 // Limit parallel requests to avoid rate limiting
sem := make(chan struct{}, maxConcurrency)
type result struct {
index int
item threadItem
err error
}
results := make(chan result, len(threads))
var wg sync.WaitGroup
for i, t := range threads {
if t.Id == "" {
continue
}
wg.Add(1)
go func(idx int, threadID string) {
defer wg.Done()
// Acquire semaphore
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
results <- result{index: idx, err: ctx.Err()}
return
}
thread, err := svc.Users.Threads.Get("me", threadID).
Format("metadata").
MetadataHeaders("From", "Subject", "Date").
Context(ctx).
Do()
if err != nil {
results <- result{index: idx, err: err}
return
}
item := threadItem{ID: threadID}
if msg := firstMessage(thread); msg != nil {
item.Date = formatGmailDate(headerValue(msg.Payload, "Date"))
item.From = sanitizeTab(headerValue(msg.Payload, "From"))
item.Subject = sanitizeTab(headerValue(msg.Payload, "Subject"))
if len(msg.LabelIds) > 0 {
names := make([]string, 0, len(msg.LabelIds))
for _, id := range msg.LabelIds {
if n, ok := idToName[id]; ok {
names = append(names, n)
} else {
names = append(names, id)
}
}
item.Labels = names
}
}
results <- result{index: idx, item: item}
}(i, t.Id)
}
// Close results channel when all goroutines complete
go func() {
wg.Wait()
close(results)
}()
// Collect results in order
items := make([]threadItem, len(threads))
validCount := 0
for r := range results {
if r.err != nil {
return nil, r.err
}
items[r.index] = r.item
validCount++
}
// Filter out empty items (from threads with empty IDs)
filtered := make([]threadItem, 0, validCount)
for _, item := range items {
if item.ID != "" {
filtered = append(filtered, item)
}
}
return filtered, nil
}

View File

@ -0,0 +1,164 @@
package cmd
import (
"errors"
"os"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailAutoForwardCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "autoforward",
Short: "Manage auto-forwarding settings",
Long: `Manage auto-forwarding settings.
The email address must first be verified via 'gmail forwarding create' before it can be used
for auto-forwarding.`,
}
cmd.AddCommand(newGmailAutoForwardGetCmd(flags))
cmd.AddCommand(newGmailAutoForwardUpdateCmd(flags))
return cmd
}
func newGmailAutoForwardGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get",
Short: "Get current auto-forwarding settings",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
autoForward, err := svc.Users.Settings.GetAutoForwarding("me").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"autoForwarding": autoForward})
}
u.Out().Printf("enabled\t%t", autoForward.Enabled)
if autoForward.EmailAddress != "" {
u.Out().Printf("email_address\t%s", autoForward.EmailAddress)
}
if autoForward.Disposition != "" {
u.Out().Printf("disposition\t%s", autoForward.Disposition)
}
return nil
},
}
}
func newGmailAutoForwardUpdateCmd(flags *rootFlags) *cobra.Command {
var enable bool
var disable bool
var email string
var disposition string
cmd := &cobra.Command{
Use: "update",
Short: "Update auto-forwarding settings",
Long: `Update auto-forwarding settings.
The email address must first be verified via 'gmail forwarding create' before it can be used.
Valid disposition values:
- leaveInInbox: Leave forwarded messages in inbox
- archive: Archive forwarded messages
- trash: Move forwarded messages to trash
- markRead: Mark forwarded messages as read`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if enable && disable {
return errors.New("cannot specify both --enable and --disable")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
// Get current settings first
current, err := svc.Users.Settings.GetAutoForwarding("me").Do()
if err != nil {
return err
}
// Build update request, preserving existing values if not specified
autoForward := &gmail.AutoForwarding{
Enabled: current.Enabled,
EmailAddress: current.EmailAddress,
Disposition: current.Disposition,
}
// Apply flags
if enable {
autoForward.Enabled = true
}
if disable {
autoForward.Enabled = false
}
if cmd.Flags().Changed("email") {
autoForward.EmailAddress = email
}
if cmd.Flags().Changed("disposition") {
// Validate disposition value
validDispositions := map[string]bool{
"leaveInInbox": true,
"archive": true,
"trash": true,
"markRead": true,
}
if !validDispositions[disposition] {
return errors.New("invalid disposition value; must be one of: leaveInInbox, archive, trash, markRead")
}
autoForward.Disposition = disposition
}
updated, err := svc.Users.Settings.UpdateAutoForwarding("me", autoForward).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"autoForwarding": updated})
}
u.Out().Println("Auto-forwarding settings updated successfully")
u.Out().Printf("enabled\t%t", updated.Enabled)
if updated.EmailAddress != "" {
u.Out().Printf("email_address\t%s", updated.EmailAddress)
}
if updated.Disposition != "" {
u.Out().Printf("disposition\t%s", updated.Disposition)
}
return nil
},
}
cmd.Flags().BoolVar(&enable, "enable", false, "Enable auto-forwarding")
cmd.Flags().BoolVar(&disable, "disable", false, "Disable auto-forwarding")
cmd.Flags().StringVar(&email, "email", "", "Email address to forward to (must be verified first)")
cmd.Flags().StringVar(&disposition, "disposition", "", "What to do with forwarded messages: leaveInInbox, archive, trash, markRead")
return cmd
}

View File

@ -0,0 +1,68 @@
package cmd
import (
"testing"
)
func TestAutoForwardCommandExists(t *testing.T) {
// Unit tests for the actual API call live in integration; here we just ensure
// the command exists and is properly structured. (Compile-time coverage.)
_ = newGmailAutoForwardCmd
_ = newGmailAutoForwardGetCmd
_ = newGmailAutoForwardUpdateCmd
}
func TestValidateDisposition(t *testing.T) {
tests := []struct {
name string
value string
isValid bool
}{
{
name: "leaveInInbox is valid",
value: "leaveInInbox",
isValid: true,
},
{
name: "archive is valid",
value: "archive",
isValid: true,
},
{
name: "trash is valid",
value: "trash",
isValid: true,
},
{
name: "markRead is valid",
value: "markRead",
isValid: true,
},
{
name: "invalid value",
value: "deleteForever",
isValid: false,
},
{
name: "empty string",
value: "",
isValid: false,
},
}
validDispositions := map[string]bool{
"leaveInInbox": true,
"archive": true,
"trash": true,
"markRead": true,
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := validDispositions[tt.value]
if got != tt.isValid {
t.Errorf("disposition %q: got valid=%v, want valid=%v", tt.value, got, tt.isValid)
}
})
}
}

123
internal/cmd/gmail_batch.go Normal file
View File

@ -0,0 +1,123 @@
package cmd
import (
"errors"
"os"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailBatchCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "batch",
Short: "Batch operations on messages",
}
cmd.AddCommand(newGmailBatchDeleteCmd(flags))
cmd.AddCommand(newGmailBatchModifyCmd(flags))
return cmd
}
func newGmailBatchDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <messageIds...>",
Short: "Permanently delete multiple messages",
Long: `Permanently delete multiple messages. This action cannot be undone.
The messages are immediately and permanently deleted, not moved to trash.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
err = svc.Users.Messages.BatchDelete("me", &gmail.BatchDeleteMessagesRequest{
Ids: args,
}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"deleted": args,
"count": len(args),
})
}
u.Out().Printf("Deleted %d messages", len(args))
return nil
},
}
}
func newGmailBatchModifyCmd(flags *rootFlags) *cobra.Command {
var add string
var remove string
cmd := &cobra.Command{
Use: "modify <messageIds...>",
Short: "Modify labels on multiple messages",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
addLabels := splitCSV(add)
removeLabels := splitCSV(remove)
if len(addLabels) == 0 && len(removeLabels) == 0 {
return errors.New("must specify --add and/or --remove")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
idMap, err := fetchLabelNameToID(svc)
if err != nil {
return err
}
addIDs := resolveLabelIDs(addLabels, idMap)
removeIDs := resolveLabelIDs(removeLabels, idMap)
err = svc.Users.Messages.BatchModify("me", &gmail.BatchModifyMessagesRequest{
Ids: args,
AddLabelIds: addIDs,
RemoveLabelIds: removeIDs,
}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"modified": args,
"count": len(args),
"addedLabels": addIDs,
"removedLabels": removeIDs,
})
}
u.Out().Printf("Modified %d messages", len(args))
return nil
},
}
cmd.Flags().StringVar(&add, "add", "", "Labels to add (comma-separated, name or ID)")
cmd.Flags().StringVar(&remove, "remove", "", "Labels to remove (comma-separated, name or ID)")
return cmd
}

View File

@ -0,0 +1,183 @@
package cmd
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
func TestFetchThreadDetails_Empty(t *testing.T) {
items, err := fetchThreadDetails(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(items) != 0 {
t.Errorf("expected empty items, got %d", len(items))
}
}
func TestFetchThreadDetails_Concurrent(t *testing.T) {
// Create a mock server that returns thread data
mux := http.NewServeMux()
// Track calls to verify concurrency (atomic for thread safety)
var callCount atomic.Int32
mux.HandleFunc("/gmail/v1/users/me/threads/", func(w http.ResponseWriter, r *http.Request) {
callCount.Add(1)
threadID := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/threads/")
response := fmt.Sprintf(`{
"id": "%s",
"messages": [{
"id": "msg_%s",
"labelIds": ["INBOX"],
"payload": {
"headers": [
{"name": "From", "value": "test@example.com"},
{"name": "Subject", "value": "Test Subject %s"},
{"name": "Date", "value": "Mon, 01 Jan 2024 10:00:00 +0000"}
]
}
}]
}`, threadID, threadID, threadID)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
})
server := httptest.NewServer(mux)
defer server.Close()
// Create Gmail service pointing to mock server
svc, err := gmail.NewService(context.Background(),
option.WithEndpoint(server.URL),
option.WithHTTPClient(http.DefaultClient),
)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
threads := []*gmail.Thread{
{Id: "thread1"},
{Id: "thread2"},
{Id: "thread3"},
}
idToName := map[string]string{
"INBOX": "Inbox",
}
items, err := fetchThreadDetails(context.Background(), svc, threads, idToName)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(items) != 3 {
t.Errorf("expected 3 items, got %d", len(items))
}
// Verify all threads were fetched
if callCount.Load() != 3 {
t.Errorf("expected 3 API calls, got %d", callCount.Load())
}
// Verify items have correct data (order should be preserved)
for i, item := range items {
expectedID := fmt.Sprintf("thread%d", i+1)
if item.ID != expectedID {
t.Errorf("item %d: expected ID %s, got %s", i, expectedID, item.ID)
}
if item.From != "test@example.com" {
t.Errorf("item %d: expected From test@example.com, got %s", i, item.From)
}
if !strings.Contains(item.Subject, "Test Subject") {
t.Errorf("item %d: expected Subject to contain 'Test Subject', got %s", i, item.Subject)
}
if len(item.Labels) != 1 || item.Labels[0] != "Inbox" {
t.Errorf("item %d: expected Labels [Inbox], got %v", i, item.Labels)
}
}
}
func TestFetchThreadDetails_SkipsEmptyIDs(t *testing.T) {
mux := http.NewServeMux()
var callCount atomic.Int32
mux.HandleFunc("/gmail/v1/users/me/threads/", func(w http.ResponseWriter, r *http.Request) {
callCount.Add(1)
threadID := strings.TrimPrefix(r.URL.Path, "/gmail/v1/users/me/threads/")
response := fmt.Sprintf(`{"id": "%s", "messages": []}`, threadID)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
})
server := httptest.NewServer(mux)
defer server.Close()
svc, _ := gmail.NewService(context.Background(),
option.WithEndpoint(server.URL),
option.WithHTTPClient(http.DefaultClient),
)
threads := []*gmail.Thread{
{Id: ""}, // Should be skipped
{Id: "thread1"}, // Should be processed
{Id: ""}, // Should be skipped
}
items, err := fetchThreadDetails(context.Background(), svc, threads, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Only 1 API call should be made (for thread1)
if callCount.Load() != 1 {
t.Errorf("expected 1 API call (skipping empty IDs), got %d", callCount.Load())
}
if len(items) != 1 {
t.Errorf("expected 1 item, got %d", len(items))
}
}
func TestFetchThreadDetails_ContextCanceled(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/gmail/v1/users/me/threads/", func(w http.ResponseWriter, r *http.Request) {
// Simulate slow response
select {
case <-r.Context().Done():
return
default:
response := `{"id": "thread1", "messages": []}`
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(response))
}
})
server := httptest.NewServer(mux)
defer server.Close()
svc, _ := gmail.NewService(context.Background(),
option.WithEndpoint(server.URL),
option.WithHTTPClient(http.DefaultClient),
)
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
threads := []*gmail.Thread{{Id: "thread1"}}
_, err := fetchThreadDetails(ctx, svc, threads, nil)
// Context was canceled, we may or may not get an error depending on timing.
// Either nil or context.Canceled is acceptable.
_ = err
}

View File

@ -0,0 +1,189 @@
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailDelegatesCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "delegates",
Short: "Manage email delegation (G Suite/Workspace feature)",
Long: `Manage email delegation settings.
Delegation allows someone else to read, send, and delete messages on your behalf.
This is a G Suite/Workspace feature and may not be available for personal Gmail accounts.`,
}
cmd.AddCommand(newGmailDelegatesListCmd(flags))
cmd.AddCommand(newGmailDelegatesGetCmd(flags))
cmd.AddCommand(newGmailDelegatesAddCmd(flags))
cmd.AddCommand(newGmailDelegatesRemoveCmd(flags))
return cmd
}
func newGmailDelegatesListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all delegates",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Users.Settings.Delegates.List("me").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"delegates": resp.Delegates})
}
if len(resp.Delegates) == 0 {
u.Err().Println("No delegates")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "EMAIL\tSTATUS")
for _, d := range resp.Delegates {
fmt.Fprintf(tw, "%s\t%s\n",
d.DelegateEmail,
d.VerificationStatus)
}
_ = tw.Flush()
return nil
},
}
}
func newGmailDelegatesGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get <delegateEmail>",
Short: "Get a specific delegate's information",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
delegateEmail := args[0]
delegate, err := svc.Users.Settings.Delegates.Get("me", delegateEmail).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"delegate": delegate})
}
u.Out().Printf("delegate_email\t%s", delegate.DelegateEmail)
u.Out().Printf("verification_status\t%s", delegate.VerificationStatus)
return nil
},
}
}
func newGmailDelegatesAddCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "add <delegateEmail>",
Short: "Add a delegate",
Long: `Add a delegate to your mailbox.
The delegate will receive an email invitation that they must accept.
Once accepted, they can read, send, and delete messages on your behalf.
Note: This is a G Suite/Workspace feature and may not be available for personal Gmail accounts.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
delegateEmail := args[0]
delegate := &gmail.Delegate{
DelegateEmail: delegateEmail,
}
created, err := svc.Users.Settings.Delegates.Create("me", delegate).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"delegate": created})
}
u.Out().Println("Delegate added successfully")
u.Out().Printf("delegate_email\t%s", created.DelegateEmail)
u.Out().Printf("verification_status\t%s", created.VerificationStatus)
u.Out().Println("\nThe delegate will receive an invitation email that they must accept.")
return nil
},
}
}
func newGmailDelegatesRemoveCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "remove <delegateEmail>",
Short: "Remove a delegate",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
delegateEmail := args[0]
err = svc.Users.Settings.Delegates.Delete("me", delegateEmail).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"success": true,
"delegateEmail": delegateEmail,
})
}
u.Out().Printf("Delegate %s removed successfully", delegateEmail)
return nil
},
}
}

View File

@ -0,0 +1,13 @@
package cmd
import "testing"
func TestDelegatesCommandsExist(t *testing.T) {
// Unit tests for the actual API calls live in integration; here we just ensure
// the commands exist and are properly structured. (Compile-time coverage.)
_ = newGmailDelegatesCmd
_ = newGmailDelegatesListCmd
_ = newGmailDelegatesGetCmd
_ = newGmailDelegatesAddCmd
_ = newGmailDelegatesRemoveCmd
}

View File

@ -306,11 +306,15 @@ func newGmailDraftsCreateCmd(flags *rootFlags) *cobra.Command {
var replyToMessageID string
var replyTo string
var attach []string
var from string
cmd := &cobra.Command{
Use: "create",
Short: "Create a draft",
Args: cobra.NoArgs,
Long: `Create a draft. Use --from to send from a configured send-as alias.
To see available send-as aliases: gog gmail sendas list`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
@ -329,6 +333,25 @@ func newGmailDraftsCreateCmd(flags *rootFlags) *cobra.Command {
return err
}
// Determine the From address
fromAddr := account
if strings.TrimSpace(from) != "" {
// Validate that this is a configured send-as alias
var sa *gmail.SendAs
sa, err = svc.Users.Settings.SendAs.Get("me", from).Context(cmd.Context()).Do()
if err != nil {
return fmt.Errorf("invalid --from address %q: %w", from, err)
}
if sa.VerificationStatus != "accepted" {
return fmt.Errorf("--from address %q is not verified (status: %s)", from, sa.VerificationStatus)
}
fromAddr = from
// Include display name if set
if sa.DisplayName != "" {
fromAddr = sa.DisplayName + " <" + from + ">"
}
}
inReplyTo, references, threadID, err := replyHeaders(cmd, svc, replyToMessageID)
if err != nil {
return err
@ -340,7 +363,7 @@ func newGmailDraftsCreateCmd(flags *rootFlags) *cobra.Command {
}
raw, err := buildRFC822(mailOptions{
From: account,
From: fromAddr,
To: splitCSV(to),
Cc: splitCSV(cc),
Bcc: splitCSV(bcc),
@ -394,5 +417,6 @@ func newGmailDraftsCreateCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().StringVar(&replyToMessageID, "reply-to-message-id", "", "Reply to Gmail message ID (sets In-Reply-To/References and thread)")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply-To header address")
cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attachment file path (repeatable)")
cmd.Flags().StringVar(&from, "from", "", "Send from this email address (must be a verified send-as alias)")
return cmd
}

View File

@ -0,0 +1,388 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailFiltersCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "filters",
Short: "Manage email filters",
}
cmd.AddCommand(newGmailFiltersListCmd(flags))
cmd.AddCommand(newGmailFiltersGetCmd(flags))
cmd.AddCommand(newGmailFiltersCreateCmd(flags))
cmd.AddCommand(newGmailFiltersDeleteCmd(flags))
return cmd
}
func newGmailFiltersListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all email filters",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Users.Settings.Filters.List("me").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"filters": resp.Filter})
}
if len(resp.Filter) == 0 {
u.Err().Println("No filters")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tFROM\tTO\tSUBJECT\tQUERY")
for _, f := range resp.Filter {
criteria := f.Criteria
from := ""
to := ""
subject := ""
query := ""
if criteria != nil {
from = criteria.From
to = criteria.To
subject = criteria.Subject
query = criteria.Query
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
f.Id,
sanitizeTab(from),
sanitizeTab(to),
sanitizeTab(subject),
sanitizeTab(query))
}
_ = tw.Flush()
return nil
},
}
}
func newGmailFiltersGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get <filterId>",
Short: "Get a specific filter",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
filterID := args[0]
filter, err := svc.Users.Settings.Filters.Get("me", filterID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"filter": filter})
}
u.Out().Printf("id\t%s", filter.Id)
if filter.Criteria != nil {
c := filter.Criteria
if c.From != "" {
u.Out().Printf("from\t%s", c.From)
}
if c.To != "" {
u.Out().Printf("to\t%s", c.To)
}
if c.Subject != "" {
u.Out().Printf("subject\t%s", c.Subject)
}
if c.Query != "" {
u.Out().Printf("query\t%s", c.Query)
}
if c.HasAttachment {
u.Out().Printf("has_attachment\ttrue")
}
if c.NegatedQuery != "" {
u.Out().Printf("negated_query\t%s", c.NegatedQuery)
}
if c.Size != 0 {
u.Out().Printf("size\t%d", c.Size)
}
if c.SizeComparison != "" {
u.Out().Printf("size_comparison\t%s", c.SizeComparison)
}
if c.ExcludeChats {
u.Out().Printf("exclude_chats\ttrue")
}
}
if filter.Action != nil {
a := filter.Action
if len(a.AddLabelIds) > 0 {
u.Out().Printf("add_label_ids\t%s", strings.Join(a.AddLabelIds, ","))
}
if len(a.RemoveLabelIds) > 0 {
u.Out().Printf("remove_label_ids\t%s", strings.Join(a.RemoveLabelIds, ","))
}
if a.Forward != "" {
u.Out().Printf("forward\t%s", a.Forward)
}
}
return nil
},
}
}
func newGmailFiltersCreateCmd(flags *rootFlags) *cobra.Command {
var from string
var to string
var subject string
var query string
var hasAttachment bool
var addLabel string
var removeLabel string
var archive bool
var markRead bool
var star bool
var forward string
var trash bool
var neverSpam bool
var important bool
cmd := &cobra.Command{
Use: "create",
Short: "Create a new email filter",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
// Validate that at least one criteria is specified
if from == "" && to == "" && subject == "" && query == "" && !hasAttachment {
return errors.New("must specify at least one criteria flag (--from, --to, --subject, --query, or --has-attachment)")
}
// Validate that at least one action is specified
if addLabel == "" && removeLabel == "" && !archive && !markRead && !star && forward == "" && !trash && !neverSpam && !important {
return errors.New("must specify at least one action flag (--add-label, --remove-label, --archive, --mark-read, --star, --forward, --trash, --never-spam, or --important)")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
// Build filter criteria
criteria := &gmail.FilterCriteria{}
if from != "" {
criteria.From = from
}
if to != "" {
criteria.To = to
}
if subject != "" {
criteria.Subject = subject
}
if query != "" {
criteria.Query = query
}
if hasAttachment {
criteria.HasAttachment = true
}
// Build filter actions
action := &gmail.FilterAction{}
// Resolve label names to IDs for add/remove operations
var labelMap map[string]string
if addLabel != "" || removeLabel != "" {
labelMap, err = fetchLabelNameToID(svc)
if err != nil {
return err
}
}
if addLabel != "" {
addLabels := splitCSV(addLabel)
addIDs := resolveLabelIDs(addLabels, labelMap)
action.AddLabelIds = addIDs
}
if removeLabel != "" {
removeLabels := splitCSV(removeLabel)
removeIDs := resolveLabelIDs(removeLabels, labelMap)
action.RemoveLabelIds = removeIDs
}
if archive {
// Archive means remove from INBOX
if action.RemoveLabelIds == nil {
action.RemoveLabelIds = []string{}
}
action.RemoveLabelIds = append(action.RemoveLabelIds, "INBOX")
}
if markRead {
// Mark as read means remove UNREAD label
if action.RemoveLabelIds == nil {
action.RemoveLabelIds = []string{}
}
action.RemoveLabelIds = append(action.RemoveLabelIds, "UNREAD")
}
if star {
// Star means add STARRED label
if action.AddLabelIds == nil {
action.AddLabelIds = []string{}
}
action.AddLabelIds = append(action.AddLabelIds, "STARRED")
}
if forward != "" {
action.Forward = forward
}
if trash {
// Trash means add TRASH label
if action.AddLabelIds == nil {
action.AddLabelIds = []string{}
}
action.AddLabelIds = append(action.AddLabelIds, "TRASH")
}
if neverSpam {
// Never spam means remove SPAM label
if action.RemoveLabelIds == nil {
action.RemoveLabelIds = []string{}
}
action.RemoveLabelIds = append(action.RemoveLabelIds, "SPAM")
}
if important {
// Important means add IMPORTANT label
if action.AddLabelIds == nil {
action.AddLabelIds = []string{}
}
action.AddLabelIds = append(action.AddLabelIds, "IMPORTANT")
}
filter := &gmail.Filter{
Criteria: criteria,
Action: action,
}
created, err := svc.Users.Settings.Filters.Create("me", filter).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"filter": created})
}
u.Out().Println("Filter created successfully")
u.Out().Printf("id\t%s", created.Id)
if created.Criteria != nil {
c := created.Criteria
if c.From != "" {
u.Out().Printf("from\t%s", c.From)
}
if c.To != "" {
u.Out().Printf("to\t%s", c.To)
}
if c.Subject != "" {
u.Out().Printf("subject\t%s", c.Subject)
}
if c.Query != "" {
u.Out().Printf("query\t%s", c.Query)
}
}
return nil
},
}
// Criteria flags
cmd.Flags().StringVar(&from, "from", "", "Match messages from this sender")
cmd.Flags().StringVar(&to, "to", "", "Match messages to this recipient")
cmd.Flags().StringVar(&subject, "subject", "", "Match messages with this subject")
cmd.Flags().StringVar(&query, "query", "", "Advanced Gmail search query for matching")
cmd.Flags().BoolVar(&hasAttachment, "has-attachment", false, "Match messages with attachments")
// Action flags
cmd.Flags().StringVar(&addLabel, "add-label", "", "Label(s) to add to matching messages (comma-separated, name or ID)")
cmd.Flags().StringVar(&removeLabel, "remove-label", "", "Label(s) to remove from matching messages (comma-separated, name or ID)")
cmd.Flags().BoolVar(&archive, "archive", false, "Archive matching messages (skip inbox)")
cmd.Flags().BoolVar(&markRead, "mark-read", false, "Mark matching messages as read")
cmd.Flags().BoolVar(&star, "star", false, "Star matching messages")
cmd.Flags().StringVar(&forward, "forward", "", "Forward to this email address")
cmd.Flags().BoolVar(&trash, "trash", false, "Move matching messages to trash")
cmd.Flags().BoolVar(&neverSpam, "never-spam", false, "Never mark as spam")
cmd.Flags().BoolVar(&important, "important", false, "Mark as important")
return cmd
}
func newGmailFiltersDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <filterId>",
Short: "Delete a filter",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
filterID := args[0]
err = svc.Users.Settings.Filters.Delete("me", filterID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"success": true,
"filterId": filterID,
})
}
u.Out().Printf("Filter %s deleted successfully", filterID)
return nil
},
}
}

View File

@ -0,0 +1,13 @@
package cmd
import "testing"
func TestFiltersCommandsExist(t *testing.T) {
// Unit tests for the actual API calls live in integration; here we just ensure
// the commands exist and are properly structured. (Compile-time coverage.)
_ = newGmailFiltersCmd
_ = newGmailFiltersListCmd
_ = newGmailFiltersGetCmd
_ = newGmailFiltersCreateCmd
_ = newGmailFiltersDeleteCmd
}

View File

@ -0,0 +1,190 @@
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailForwardingCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "forwarding",
Short: "Manage email forwarding addresses",
Long: `Manage email forwarding addresses.
Forwarding addresses must be verified before they can be used. Creating a forwarding address
sends a verification email to the target address that must be confirmed.`,
}
cmd.AddCommand(newGmailForwardingListCmd(flags))
cmd.AddCommand(newGmailForwardingGetCmd(flags))
cmd.AddCommand(newGmailForwardingCreateCmd(flags))
cmd.AddCommand(newGmailForwardingDeleteCmd(flags))
return cmd
}
func newGmailForwardingListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all forwarding addresses",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Users.Settings.ForwardingAddresses.List("me").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"forwardingAddresses": resp.ForwardingAddresses})
}
if len(resp.ForwardingAddresses) == 0 {
u.Err().Println("No forwarding addresses")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "EMAIL\tSTATUS")
for _, f := range resp.ForwardingAddresses {
fmt.Fprintf(tw, "%s\t%s\n",
f.ForwardingEmail,
f.VerificationStatus)
}
_ = tw.Flush()
return nil
},
}
}
func newGmailForwardingGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get <forwardingEmail>",
Short: "Get a specific forwarding address",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
forwardingEmail := args[0]
address, err := svc.Users.Settings.ForwardingAddresses.Get("me", forwardingEmail).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"forwardingAddress": address})
}
u.Out().Printf("forwarding_email\t%s", address.ForwardingEmail)
u.Out().Printf("verification_status\t%s", address.VerificationStatus)
return nil
},
}
}
func newGmailForwardingCreateCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "create <forwardingEmail>",
Short: "Create/add a forwarding address",
Long: `Create/add a forwarding address.
This sends a verification email to the target address. The forwarding address
cannot be used until the recipient clicks the verification link in the email.
The verification status will be "pending" until confirmed, then "accepted".`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
forwardingEmail := args[0]
address := &gmail.ForwardingAddress{
ForwardingEmail: forwardingEmail,
}
created, err := svc.Users.Settings.ForwardingAddresses.Create("me", address).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"forwardingAddress": created})
}
u.Out().Println("Forwarding address created successfully")
u.Out().Printf("forwarding_email\t%s", created.ForwardingEmail)
u.Out().Printf("verification_status\t%s", created.VerificationStatus)
u.Out().Println("\nA verification email has been sent to the forwarding address.")
u.Out().Println("The address cannot be used until the recipient confirms the verification link.")
return nil
},
}
}
func newGmailForwardingDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <forwardingEmail>",
Short: "Delete a forwarding address",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
forwardingEmail := args[0]
err = svc.Users.Settings.ForwardingAddresses.Delete("me", forwardingEmail).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"success": true,
"forwardingEmail": forwardingEmail,
})
}
u.Out().Printf("Forwarding address %s deleted successfully", forwardingEmail)
return nil
},
}
}

View File

@ -0,0 +1,13 @@
package cmd
import "testing"
func TestForwardingCommandsExist(t *testing.T) {
// Unit tests for the actual API calls live in integration; here we just ensure
// the commands exist and are properly structured. (Compile-time coverage.)
_ = newGmailForwardingCmd
_ = newGmailForwardingListCmd
_ = newGmailForwardingGetCmd
_ = newGmailForwardingCreateCmd
_ = newGmailForwardingDeleteCmd
}

View File

@ -54,7 +54,7 @@ func newGmailLabelsGetCmd(flags *rootFlags) *cobra.Command {
id = v
}
l, err := svc.Users.Labels.Get("me", id).Do()
l, err := svc.Users.Labels.Get("me", id).Context(cmd.Context()).Do()
if err != nil {
return err
}
@ -91,7 +91,7 @@ func newGmailLabelsListCmd(flags *rootFlags) *cobra.Command {
return err
}
resp, err := svc.Users.Labels.List("me").Do()
resp, err := svc.Users.Labels.List("me").Context(cmd.Context()).Do()
if err != nil {
return err
}
@ -166,7 +166,7 @@ func newGmailLabelsModifyCmd(flags *rootFlags) *cobra.Command {
_, err := svc.Users.Threads.Modify("me", tid, &gmail.ModifyThreadRequest{
AddLabelIds: addIDs,
RemoveLabelIds: removeIDs,
}).Do()
}).Context(cmd.Context()).Do()
if err != nil {
results = append(results, result{ThreadID: tid, Success: false, Error: err.Error()})
if !outfmt.IsJSON(cmd.Context()) {

View File

@ -232,8 +232,8 @@ func writeBodyWithTrailingCRLF(b *bytes.Buffer, body string) {
}
func writeTextPart(b *bytes.Buffer, boundary string, contentType string, body string) {
b.WriteString(fmt.Sprintf("--%s\r\n", boundary))
b.WriteString(fmt.Sprintf("Content-Type: %s\r\n", contentType))
_, _ = fmt.Fprintf(b, "--%s\r\n", boundary)
_, _ = fmt.Fprintf(b, "Content-Type: %s\r\n", contentType)
b.WriteString("Content-Transfer-Encoding: 7bit\r\n\r\n")
writeBodyWithTrailingCRLF(b, body)
}

View File

@ -2,6 +2,7 @@ package cmd
import (
"encoding/base64"
"fmt"
"os"
"strings"
@ -21,17 +22,22 @@ func newGmailSendCmd(flags *rootFlags) *cobra.Command {
var replyToMessageID string
var replyTo string
var attach []string
var from string
cmd := &cobra.Command{
Use: "send",
Short: "Send an email",
Args: cobra.NoArgs,
Long: `Send an email. Use --from to send from a configured send-as alias.
To see available send-as aliases: gog gmail sendas list`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" {
return usage("required: --to, --subject")
}
@ -44,6 +50,25 @@ func newGmailSendCmd(flags *rootFlags) *cobra.Command {
return err
}
// Determine the From address
fromAddr := account
if strings.TrimSpace(from) != "" {
// Validate that this is a configured send-as alias
var sa *gmail.SendAs
sa, err = svc.Users.Settings.SendAs.Get("me", from).Context(cmd.Context()).Do()
if err != nil {
return fmt.Errorf("invalid --from address %q: %w", from, err)
}
if sa.VerificationStatus != "accepted" {
return fmt.Errorf("--from address %q is not verified (status: %s)", from, sa.VerificationStatus)
}
fromAddr = from
// Include display name if set
if sa.DisplayName != "" {
fromAddr = sa.DisplayName + " <" + from + ">"
}
}
inReplyTo, references, threadID, err := replyHeaders(cmd, svc, replyToMessageID)
if err != nil {
return err
@ -55,7 +80,7 @@ func newGmailSendCmd(flags *rootFlags) *cobra.Command {
}
raw, err := buildRFC822(mailOptions{
From: account,
From: fromAddr,
To: splitCSV(to),
Cc: splitCSV(cc),
Bcc: splitCSV(bcc),
@ -78,7 +103,7 @@ func newGmailSendCmd(flags *rootFlags) *cobra.Command {
msg.ThreadId = threadID
}
sent, err := svc.Users.Messages.Send("me", msg).Do()
sent, err := svc.Users.Messages.Send("me", msg).Context(cmd.Context()).Do()
if err != nil {
return err
}
@ -86,6 +111,7 @@ func newGmailSendCmd(flags *rootFlags) *cobra.Command {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"messageId": sent.Id,
"threadId": sent.ThreadId,
"from": fromAddr,
})
}
u.Out().Printf("message_id\t%s", sent.Id)
@ -105,6 +131,7 @@ func newGmailSendCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().StringVar(&replyToMessageID, "reply-to-message-id", "", "Reply to Gmail message ID (sets In-Reply-To/References and thread)")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply-To header address")
cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attachment file path (repeatable)")
cmd.Flags().StringVar(&from, "from", "", "Send from this email address (must be a verified send-as alias)")
return cmd
}

View File

@ -0,0 +1,336 @@
package cmd
import (
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailSendAsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "sendas",
Short: "Manage send-as aliases (send email from different addresses)",
}
cmd.AddCommand(newGmailSendAsListCmd(flags))
cmd.AddCommand(newGmailSendAsGetCmd(flags))
cmd.AddCommand(newGmailSendAsCreateCmd(flags))
cmd.AddCommand(newGmailSendAsVerifyCmd(flags))
cmd.AddCommand(newGmailSendAsDeleteCmd(flags))
cmd.AddCommand(newGmailSendAsUpdateCmd(flags))
return cmd
}
func newGmailSendAsListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List send-as aliases",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Users.Settings.SendAs.List("me").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"sendAs": resp.SendAs})
}
if len(resp.SendAs) == 0 {
u.Err().Println("No send-as aliases")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "EMAIL\tDISPLAY NAME\tDEFAULT\tVERIFIED\tTREAT AS ALIAS")
for _, sa := range resp.SendAs {
isDefault := ""
if sa.IsDefault {
isDefault = "yes"
}
verified := "pending"
if sa.VerificationStatus == "accepted" {
verified = "yes"
}
treatAsAlias := ""
if sa.TreatAsAlias {
treatAsAlias = "yes"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
sa.SendAsEmail, sa.DisplayName, isDefault, verified, treatAsAlias)
}
_ = tw.Flush()
return nil
},
}
}
func newGmailSendAsGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get <email>",
Short: "Get details of a send-as alias",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
sendAsEmail := strings.TrimSpace(args[0])
if sendAsEmail == "" {
return errors.New("email is required")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
sa, err := svc.Users.Settings.SendAs.Get("me", sendAsEmail).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"sendAs": sa})
}
u.Out().Printf("send_as_email\t%s", sa.SendAsEmail)
u.Out().Printf("display_name\t%s", sa.DisplayName)
u.Out().Printf("reply_to\t%s", sa.ReplyToAddress)
u.Out().Printf("signature\t%s", sa.Signature)
u.Out().Printf("is_primary\t%t", sa.IsPrimary)
u.Out().Printf("is_default\t%t", sa.IsDefault)
u.Out().Printf("treat_as_alias\t%t", sa.TreatAsAlias)
u.Out().Printf("verification_status\t%s", sa.VerificationStatus)
return nil
},
}
}
func newGmailSendAsCreateCmd(flags *rootFlags) *cobra.Command {
var displayName string
var replyTo string
var signature string
var treatAsAlias bool
cmd := &cobra.Command{
Use: "create <email>",
Short: "Create a new send-as alias",
Long: `Create a new send-as alias. After creation, a verification email will be sent
to the specified address. The alias cannot be used until verified.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
sendAsEmail := strings.TrimSpace(args[0])
if sendAsEmail == "" {
return errors.New("email is required")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
sendAs := &gmail.SendAs{
SendAsEmail: sendAsEmail,
DisplayName: displayName,
ReplyToAddress: replyTo,
Signature: signature,
TreatAsAlias: treatAsAlias,
}
created, err := svc.Users.Settings.SendAs.Create("me", sendAs).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"sendAs": created})
}
u.Out().Printf("send_as_email\t%s", created.SendAsEmail)
u.Out().Printf("verification_status\t%s", created.VerificationStatus)
u.Err().Println("Verification email sent. Check your inbox to complete setup.")
return nil
},
}
cmd.Flags().StringVar(&displayName, "display-name", "", "Name that appears in the From field")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply-to address (optional)")
cmd.Flags().StringVar(&signature, "signature", "", "HTML signature for emails sent from this alias")
cmd.Flags().BoolVar(&treatAsAlias, "treat-as-alias", true, "Treat as alias (replies sent from Gmail web)")
return cmd
}
func newGmailSendAsVerifyCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "verify <email>",
Short: "Resend verification email for a send-as alias",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
sendAsEmail := strings.TrimSpace(args[0])
if sendAsEmail == "" {
return errors.New("email is required")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
err = svc.Users.Settings.SendAs.Verify("me", sendAsEmail).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"email": sendAsEmail,
"message": "Verification email sent",
})
}
u.Out().Printf("Verification email sent to %s", sendAsEmail)
return nil
},
}
}
func newGmailSendAsDeleteCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "delete <email>",
Short: "Delete a send-as alias",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
sendAsEmail := strings.TrimSpace(args[0])
if sendAsEmail == "" {
return errors.New("email is required")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
err = svc.Users.Settings.SendAs.Delete("me", sendAsEmail).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"email": sendAsEmail,
"deleted": true,
})
}
u.Out().Printf("Deleted send-as alias: %s", sendAsEmail)
return nil
},
}
}
func newGmailSendAsUpdateCmd(flags *rootFlags) *cobra.Command {
var displayName string
var replyTo string
var signature string
var treatAsAlias bool
var makeDefault bool
cmd := &cobra.Command{
Use: "update <email>",
Short: "Update a send-as alias",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
sendAsEmail := strings.TrimSpace(args[0])
if sendAsEmail == "" {
return errors.New("email is required")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
// Get current settings first
current, err := svc.Users.Settings.SendAs.Get("me", sendAsEmail).Do()
if err != nil {
return err
}
// Update only provided fields
if cmd.Flags().Changed("display-name") {
current.DisplayName = displayName
}
if cmd.Flags().Changed("reply-to") {
current.ReplyToAddress = replyTo
}
if cmd.Flags().Changed("signature") {
current.Signature = signature
}
if cmd.Flags().Changed("treat-as-alias") {
current.TreatAsAlias = treatAsAlias
}
if cmd.Flags().Changed("make-default") {
current.IsDefault = makeDefault
}
updated, err := svc.Users.Settings.SendAs.Update("me", sendAsEmail, current).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"sendAs": updated})
}
u.Out().Printf("Updated send-as alias: %s", updated.SendAsEmail)
return nil
},
}
cmd.Flags().StringVar(&displayName, "display-name", "", "Name that appears in the From field")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply-to address")
cmd.Flags().StringVar(&signature, "signature", "", "HTML signature")
cmd.Flags().BoolVar(&treatAsAlias, "treat-as-alias", true, "Treat as alias")
cmd.Flags().BoolVar(&makeDefault, "make-default", false, "Make this the default send-as address")
return cmd
}

View File

@ -0,0 +1,572 @@
package cmd
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
func TestGmailSendAsListCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/settings/sendAs") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAs": []map[string]any{
{
"sendAsEmail": "primary@example.com",
"displayName": "Primary User",
"isDefault": true,
"isPrimary": true,
"treatAsAlias": false,
"verificationStatus": "accepted",
},
{
"sendAsEmail": "work@company.com",
"displayName": "Work Alias",
"isDefault": false,
"isPrimary": false,
"treatAsAlias": true,
"verificationStatus": "accepted",
},
},
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailSendAsListCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
var parsed struct {
SendAs []struct {
SendAsEmail string `json:"sendAsEmail"`
DisplayName string `json:"displayName"`
IsDefault bool `json:"isDefault"`
VerificationStatus string `json:"verificationStatus"`
} `json:"sendAs"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if len(parsed.SendAs) != 2 {
t.Fatalf("unexpected sendAs count: %d", len(parsed.SendAs))
}
if parsed.SendAs[0].SendAsEmail != "primary@example.com" {
t.Fatalf("unexpected first sendAs: %#v", parsed.SendAs[0])
}
if parsed.SendAs[1].SendAsEmail != "work@company.com" {
t.Fatalf("unexpected second sendAs: %#v", parsed.SendAs[1])
}
}
func TestGmailSendAsGetCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/settings/sendAs/work@company.com") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "work@company.com",
"displayName": "Work Alias",
"replyToAddress": "replies@company.com",
"signature": "<b>Signature</b>",
"isDefault": false,
"isPrimary": false,
"treatAsAlias": true,
"verificationStatus": "accepted",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailSendAsGetCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"work@company.com"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
var parsed struct {
SendAs struct {
SendAsEmail string `json:"sendAsEmail"`
DisplayName string `json:"displayName"`
ReplyToAddress string `json:"replyToAddress"`
VerificationStatus string `json:"verificationStatus"`
} `json:"sendAs"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.SendAs.SendAsEmail != "work@company.com" {
t.Fatalf("unexpected sendAs: %#v", parsed.SendAs)
}
if parsed.SendAs.DisplayName != "Work Alias" {
t.Fatalf("unexpected displayName: %q", parsed.SendAs.DisplayName)
}
}
func TestGmailBatchDeleteCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
var receivedIDs []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/messages/batchDelete") && r.Method == http.MethodPost {
var body struct {
IDs []string `json:"ids"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
receivedIDs = body.IDs
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailBatchDeleteCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"msg1", "msg2", "msg3"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
if len(receivedIDs) != 3 || receivedIDs[0] != "msg1" {
t.Fatalf("unexpected IDs sent: %v", receivedIDs)
}
var parsed struct {
Deleted []string `json:"deleted"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Count != 3 {
t.Fatalf("unexpected count: %d", parsed.Count)
}
}
func TestGmailBatchModifyCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
var receivedRequest struct {
IDs []string `json:"ids"`
AddLabelIds []string `json:"addLabelIds"`
RemoveLabelIds []string `json:"removeLabelIds"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/users/me/labels"):
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"labels": []map[string]any{
{"id": "INBOX", "name": "INBOX", "type": "system"},
{"id": "SPAM", "name": "SPAM", "type": "system"},
},
})
return
case strings.Contains(r.URL.Path, "/messages/batchModify") && r.Method == http.MethodPost:
_ = json.NewDecoder(r.Body).Decode(&receivedRequest)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailBatchModifyCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"msg1", "msg2"})
_ = cmd.Flags().Set("add", "INBOX")
_ = cmd.Flags().Set("remove", "SPAM")
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
if len(receivedRequest.IDs) != 2 {
t.Fatalf("unexpected IDs: %v", receivedRequest.IDs)
}
if len(receivedRequest.AddLabelIds) != 1 || receivedRequest.AddLabelIds[0] != "INBOX" {
t.Fatalf("unexpected addLabelIds: %v", receivedRequest.AddLabelIds)
}
if len(receivedRequest.RemoveLabelIds) != 1 || receivedRequest.RemoveLabelIds[0] != "SPAM" {
t.Fatalf("unexpected removeLabelIds: %v", receivedRequest.RemoveLabelIds)
}
var parsed struct {
Modified []string `json:"modified"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Count != 2 {
t.Fatalf("unexpected count: %d", parsed.Count)
}
}
func TestGmailSendAsCreateCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
var receivedBody map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/settings/sendAs") && r.Method == http.MethodPost {
_ = json.NewDecoder(r.Body).Decode(&receivedBody)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "alias@example.com",
"displayName": "Test Alias",
"verificationStatus": "pending",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailSendAsCreateCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"alias@example.com"})
_ = cmd.Flags().Set("display-name", "Test Alias")
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
var parsed struct {
SendAs struct {
SendAsEmail string `json:"sendAsEmail"`
VerificationStatus string `json:"verificationStatus"`
} `json:"sendAs"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.SendAs.SendAsEmail != "alias@example.com" {
t.Fatalf("unexpected sendAs: %#v", parsed.SendAs)
}
if parsed.SendAs.VerificationStatus != "pending" {
t.Fatalf("unexpected status: %q", parsed.SendAs.VerificationStatus)
}
}
func TestGmailSendAsDeleteCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
var deletedEmail string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/settings/sendAs/") && r.Method == http.MethodDelete {
parts := strings.Split(r.URL.Path, "/")
deletedEmail = parts[len(parts)-1]
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailSendAsDeleteCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"delete-me@example.com"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
if deletedEmail != "delete-me@example.com" {
t.Fatalf("unexpected deleted email: %q", deletedEmail)
}
var parsed struct {
Email string `json:"email"`
Deleted bool `json:"deleted"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if !parsed.Deleted {
t.Fatalf("expected deleted=true")
}
}
func TestGmailSendAsVerifyCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
var verifiedEmail string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/settings/sendAs/") && strings.HasSuffix(r.URL.Path, "/verify") && r.Method == http.MethodPost {
parts := strings.Split(r.URL.Path, "/")
verifiedEmail = parts[len(parts)-2]
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailSendAsVerifyCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"verify-me@example.com"})
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
if verifiedEmail != "verify-me@example.com" {
t.Fatalf("unexpected verified email: %q", verifiedEmail)
}
var parsed struct {
Email string `json:"email"`
Message string `json:"message"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.Email != "verify-me@example.com" {
t.Fatalf("unexpected email: %q", parsed.Email)
}
}
func TestGmailSendAsUpdateCmd_JSON(t *testing.T) {
origNew := newGmailService
t.Cleanup(func() { newGmailService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/settings/sendAs/update@example.com") && r.Method == http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "update@example.com",
"displayName": "Old Name",
"verificationStatus": "accepted",
})
return
case strings.Contains(r.URL.Path, "/settings/sendAs/update@example.com") && r.Method == http.MethodPut:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sendAsEmail": "update@example.com",
"displayName": "New Name",
"verificationStatus": "accepted",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()
svc, err := gmail.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
flags := &rootFlags{Account: "a@b.com"}
out := captureStdout(t, func() {
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
if uiErr != nil {
t.Fatalf("ui.New: %v", uiErr)
}
ctx := ui.WithUI(context.Background(), u)
ctx = outfmt.WithMode(ctx, outfmt.Mode{JSON: true})
cmd := newGmailSendAsUpdateCmd(flags)
cmd.SetContext(ctx)
cmd.SetArgs([]string{"update@example.com"})
_ = cmd.Flags().Set("display-name", "New Name")
if err := cmd.Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
})
var parsed struct {
SendAs struct {
SendAsEmail string `json:"sendAsEmail"`
DisplayName string `json:"displayName"`
} `json:"sendAs"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("json parse: %v\nout=%q", err, out)
}
if parsed.SendAs.DisplayName != "New Name" {
t.Fatalf("unexpected displayName: %q", parsed.SendAs.DisplayName)
}
}

View File

@ -36,7 +36,7 @@ func newGmailThreadCmd(flags *rootFlags) *cobra.Command {
return err
}
thread, err := svc.Users.Threads.Get("me", threadID).Format("full").Do()
thread, err := svc.Users.Threads.Get("me", threadID).Format("full").Context(cmd.Context()).Do()
if err != nil {
return err
}
@ -235,41 +235,13 @@ func findPartBody(p *gmail.MessagePart, mimeType string) string {
}
func decodeBase64URL(s string) (string, error) {
b, err := decodeBase64URLBytes(s)
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return "", err
}
return string(b), nil
}
func decodeBase64URLBytes(s string) ([]byte, error) {
var filtered []byte
for i := 0; i < len(s); i++ {
switch s[i] {
case ' ', '\n', '\r', '\t':
if filtered == nil {
filtered = make([]byte, 0, len(s))
filtered = append(filtered, s[:i]...)
}
continue
default:
if filtered != nil {
filtered = append(filtered, s[i])
}
}
}
if filtered != nil {
s = string(filtered)
}
b, err := base64.RawURLEncoding.DecodeString(s)
if err == nil {
return b, nil
}
// Gmail API returns base64url; some responses include padding.
return base64.URLEncoding.DecodeString(s)
}
func downloadAttachment(cmd *cobra.Command, svc *gmail.Service, messageID string, a attachmentInfo, dir string) (string, bool, error) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(a.AttachmentID) == "" {
return "", false, errors.New("missing messageID/attachmentID")
@ -278,7 +250,12 @@ func downloadAttachment(cmd *cobra.Command, svc *gmail.Service, messageID string
if len(shortID) > 8 {
shortID = shortID[:8]
}
filename := fmt.Sprintf("%s_%s_%s", messageID, shortID, a.Filename)
// Sanitize filename to prevent path traversal attacks
safeFilename := filepath.Base(a.Filename)
if safeFilename == "" || safeFilename == "." || safeFilename == ".." {
safeFilename = "attachment"
}
filename := fmt.Sprintf("%s_%s_%s", messageID, shortID, safeFilename)
outPath := filepath.Join(dir, filename)
if st, err := os.Stat(outPath); err == nil && st.Size() == a.Size && a.Size > 0 {
@ -292,7 +269,7 @@ func downloadAttachment(cmd *cobra.Command, svc *gmail.Service, messageID string
if body == nil || body.Data == "" {
return "", false, errors.New("empty attachment data")
}
data, err := decodeBase64URLBytes(body.Data)
data, err := base64.RawURLEncoding.DecodeString(body.Data)
if err != nil {
return "", false, err
}

View File

@ -1,9 +1,7 @@
package cmd
import (
"bytes"
"encoding/base64"
"strings"
"testing"
"google.golang.org/api/gmail/v1"
@ -59,72 +57,7 @@ func TestDecodeBase64URL(t *testing.T) {
if got != "ok" {
t.Fatalf("unexpected: %q", got)
}
got, err = decodeBase64URL(base64.URLEncoding.EncodeToString([]byte("ok")))
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "ok" {
t.Fatalf("unexpected: %q", got)
}
if _, err := decodeBase64URL("!!!"); err == nil {
t.Fatalf("expected error")
}
}
func TestDecodeBase64URLBytes(t *testing.T) {
want := []byte{0xff, 0xff}
got, err := decodeBase64URLBytes(base64.RawURLEncoding.EncodeToString(want))
if err != nil {
t.Fatalf("err: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("unexpected: %#v", got)
}
got, err = decodeBase64URLBytes(base64.URLEncoding.EncodeToString(want))
if err != nil {
t.Fatalf("err: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("unexpected: %#v", got)
}
enc := base64.RawURLEncoding.EncodeToString(want[:1])
enc = " \t" + enc[:1] + "\r\n" + enc[1:] + "\t "
got, err = decodeBase64URLBytes(enc)
if err != nil {
t.Fatalf("err: %v", err)
}
if !bytes.Equal(got, want[:1]) {
t.Fatalf("unexpected: %#v", got)
}
// Ensure we cover the '-' base64url alphabet as well.
wantDash := []byte{0xfb}
encDash := base64.RawURLEncoding.EncodeToString(wantDash)
if !strings.Contains(encDash, "-") {
t.Fatalf("expected '-' in encoding, got: %q", encDash)
}
got, err = decodeBase64URLBytes(encDash)
if err != nil {
t.Fatalf("err: %v", err)
}
if !bytes.Equal(got, wantDash) {
t.Fatalf("unexpected: %#v", got)
}
encDashPadded := base64.URLEncoding.EncodeToString(wantDash)
encDashPadded = " " + encDashPadded[:2] + "\n" + encDashPadded[2:] + "\t"
got, err = decodeBase64URLBytes(encDashPadded)
if err != nil {
t.Fatalf("err: %v", err)
}
if !bytes.Equal(got, wantDash) {
t.Fatalf("unexpected: %#v", got)
}
if _, err := decodeBase64URLBytes("!!!"); err == nil {
t.Fatalf("expected error")
}
}

View File

@ -0,0 +1,205 @@
package cmd
import (
"errors"
"os"
"time"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailVacationCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "vacation",
Short: "Manage vacation responder settings",
}
cmd.AddCommand(newGmailVacationGetCmd(flags))
cmd.AddCommand(newGmailVacationUpdateCmd(flags))
return cmd
}
func newGmailVacationGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "get",
Short: "Get current vacation responder settings",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
vacation, err := svc.Users.Settings.GetVacation("me").Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"vacation": vacation})
}
u.Out().Printf("enable_auto_reply\t%t", vacation.EnableAutoReply)
u.Out().Printf("response_subject\t%s", vacation.ResponseSubject)
u.Out().Printf("response_body_html\t%s", vacation.ResponseBodyHtml)
u.Out().Printf("response_body_plain_text\t%s", vacation.ResponseBodyPlainText)
if vacation.StartTime != 0 {
u.Out().Printf("start_time\t%d", vacation.StartTime)
}
if vacation.EndTime != 0 {
u.Out().Printf("end_time\t%d", vacation.EndTime)
}
u.Out().Printf("restrict_to_contacts\t%t", vacation.RestrictToContacts)
u.Out().Printf("restrict_to_domain\t%t", vacation.RestrictToDomain)
return nil
},
}
}
func newGmailVacationUpdateCmd(flags *rootFlags) *cobra.Command {
var enable bool
var disable bool
var subject string
var body string
var startTime string
var endTime string
var contactsOnly bool
var domainOnly bool
cmd := &cobra.Command{
Use: "update",
Short: "Update vacation responder settings",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if enable && disable {
return errors.New("cannot specify both --enable and --disable")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
// Get current settings first
current, err := svc.Users.Settings.GetVacation("me").Do()
if err != nil {
return err
}
// Build update request, preserving existing values if not specified
vacation := &gmail.VacationSettings{
EnableAutoReply: current.EnableAutoReply,
ResponseSubject: current.ResponseSubject,
ResponseBodyHtml: current.ResponseBodyHtml,
ResponseBodyPlainText: current.ResponseBodyPlainText,
StartTime: current.StartTime,
EndTime: current.EndTime,
RestrictToContacts: current.RestrictToContacts,
RestrictToDomain: current.RestrictToDomain,
}
// Apply flags
if enable {
vacation.EnableAutoReply = true
}
if disable {
vacation.EnableAutoReply = false
}
if cmd.Flags().Changed("subject") {
vacation.ResponseSubject = subject
}
if cmd.Flags().Changed("body") {
vacation.ResponseBodyHtml = body
vacation.ResponseBodyPlainText = stripHTML(body)
}
if cmd.Flags().Changed("start") {
var t int64
t, err = parseRFC3339ToMillis(startTime)
if err != nil {
return err
}
vacation.StartTime = t
}
if cmd.Flags().Changed("end") {
var t int64
t, err = parseRFC3339ToMillis(endTime)
if err != nil {
return err
}
vacation.EndTime = t
}
if cmd.Flags().Changed("contacts-only") {
vacation.RestrictToContacts = contactsOnly
}
if cmd.Flags().Changed("domain-only") {
vacation.RestrictToDomain = domainOnly
}
updated, err := svc.Users.Settings.UpdateVacation("me", vacation).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"vacation": updated})
}
u.Out().Println("Vacation responder updated successfully")
u.Out().Printf("enable_auto_reply\t%t", updated.EnableAutoReply)
u.Out().Printf("response_subject\t%s", updated.ResponseSubject)
if updated.StartTime != 0 {
u.Out().Printf("start_time\t%d", updated.StartTime)
}
if updated.EndTime != 0 {
u.Out().Printf("end_time\t%d", updated.EndTime)
}
u.Out().Printf("restrict_to_contacts\t%t", updated.RestrictToContacts)
u.Out().Printf("restrict_to_domain\t%t", updated.RestrictToDomain)
return nil
},
}
cmd.Flags().BoolVar(&enable, "enable", false, "Enable vacation responder")
cmd.Flags().BoolVar(&disable, "disable", false, "Disable vacation responder")
cmd.Flags().StringVar(&subject, "subject", "", "Subject line for auto-reply")
cmd.Flags().StringVar(&body, "body", "", "HTML body of the auto-reply message")
cmd.Flags().StringVar(&startTime, "start", "", "Start time in RFC3339 format (e.g., 2024-12-20T00:00:00Z)")
cmd.Flags().StringVar(&endTime, "end", "", "End time in RFC3339 format (e.g., 2024-12-31T23:59:59Z)")
cmd.Flags().BoolVar(&contactsOnly, "contacts-only", false, "Only respond to contacts")
cmd.Flags().BoolVar(&domainOnly, "domain-only", false, "Only respond to same domain")
return cmd
}
func parseRFC3339ToMillis(rfc3339 string) (int64, error) {
if rfc3339 == "" {
return 0, nil
}
// Parse RFC3339 format and convert to milliseconds since epoch
t, err := time.Parse(time.RFC3339, rfc3339)
if err != nil {
return 0, err
}
return t.UnixMilli(), nil
}
func stripHTML(html string) string {
// Simple HTML stripping for plain text version
// This is a basic implementation - Gmail API will handle more complex conversions
// For now, just return the HTML as-is, Gmail will auto-convert
return html
}

View File

@ -0,0 +1,105 @@
package cmd
import (
"testing"
"time"
)
func TestParseRFC3339ToMillis(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{
name: "empty string",
input: "",
wantErr: false,
},
{
name: "valid RFC3339",
input: "2024-12-20T00:00:00Z",
wantErr: false,
},
{
name: "valid RFC3339 with timezone",
input: "2024-12-20T12:30:00-08:00",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseRFC3339ToMillis(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseRFC3339ToMillis() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.input == "" && got != 0 {
t.Errorf("parseRFC3339ToMillis() empty input should return 0, got %d", got)
}
if tt.input != "" && !tt.wantErr && got == 0 {
t.Errorf("parseRFC3339ToMillis() valid input should return non-zero, got %d", got)
}
})
}
}
func TestParseRFC3339ToMillisValue(t *testing.T) {
// Test with a known timestamp
input := "2024-12-20T00:00:00Z"
got, err := parseRFC3339ToMillis(input)
if err != nil {
t.Fatalf("parseRFC3339ToMillis() unexpected error: %v", err)
}
// Parse the same time with standard library for comparison
expected, err := time.Parse(time.RFC3339, input)
if err != nil {
t.Fatalf("time.Parse() unexpected error: %v", err)
}
if got != expected.UnixMilli() {
t.Errorf("parseRFC3339ToMillis() = %d, want %d", got, expected.UnixMilli())
}
}
func TestStripHTML(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "empty string",
input: "",
want: "",
},
{
name: "plain text",
input: "Hello world",
want: "Hello world",
},
{
name: "html content",
input: "<p>Hello world</p>",
want: "<p>Hello world</p>", // Current implementation returns as-is
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := stripHTML(tt.input); got != tt.want {
t.Errorf("stripHTML() = %v, want %v", got, tt.want)
}
})
}
}
func TestVacationCommandExists(t *testing.T) {
// Unit tests for the actual API call live in integration; here we just ensure
// the command exists and is properly structured. (Compile-time coverage.)
_ = newGmailVacationCmd
_ = newGmailVacationGetCmd
_ = newGmailVacationUpdateCmd
}

View File

@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"context"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
@ -169,11 +170,13 @@ func (s *gmailWatchServer) handlePush(ctx context.Context, payload gmailPushPayl
if historyResp != nil && historyResp.HistoryId != 0 {
nextHistoryID = formatHistoryID(historyResp.HistoryId)
}
_ = store.Update(func(state *gmailWatchState) error {
if err := store.Update(func(state *gmailWatchState) error {
state.HistoryID = nextHistoryID
state.UpdatedAtMs = time.Now().UnixMilli()
return nil
})
}); err != nil {
s.warnf("watch: failed to update state: %v", err)
}
return &gmailHookPayload{
Source: "gmail",
@ -199,11 +202,13 @@ func (s *gmailWatchServer) resyncHistory(ctx context.Context, svc *gmail.Service
return nil, err
}
_ = s.store.Update(func(state *gmailWatchState) error {
if err := s.store.Update(func(state *gmailWatchState) error {
state.HistoryID = historyID
state.UpdatedAtMs = time.Now().UnixMilli()
return nil
})
}); err != nil {
s.warnf("watch: failed to update state after resync: %v", err)
}
return &gmailHookPayload{
Source: "gmail",
@ -346,11 +351,17 @@ func bearerToken(r *http.Request) string {
}
func sharedTokenMatches(r *http.Request, expected string) bool {
if expected == "" {
return false
}
token := r.Header.Get("x-gog-token")
if token == "" {
token = r.URL.Query().Get("token")
}
return token != "" && token == expected
if token == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(token), []byte(expected)) == 1
}
func verifyOIDCToken(ctx context.Context, validator *idtoken.Validator, token, audience, expectedEmail string) (bool, error) {

View File

@ -3,6 +3,7 @@ package cmd
import (
"errors"
"fmt"
"log/slog"
"os"
"strings"
@ -20,6 +21,7 @@ type rootFlags struct {
Plain bool
Force bool
NoInput bool
Debug bool
}
func Execute(args []string) error {
@ -76,10 +78,21 @@ func Execute(args []string) error {
# People
gog people me
# Sheets
gog sheets get <spreadsheetId> 'Sheet1!A1:C10'
# Parseable output
gog --json drive ls --max 5 | jq .
`),
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
logLevel := slog.LevelWarn
if flags.Debug {
logLevel = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: logLevel,
})))
mode, err := outfmt.FromFlags(flags.JSON, flags.Plain)
if err != nil {
return err
@ -106,11 +119,12 @@ func Execute(args []string) error {
root.SetArgs(args)
root.PersistentFlags().StringVar(&flags.Color, "color", flags.Color, "Color output: auto|always|never")
root.PersistentFlags().StringVar(&flags.Account, "account", "", "Account email for API commands (gmail/calendar/drive/contacts/tasks/people)")
root.PersistentFlags().StringVar(&flags.Account, "account", "", "Account email for API commands (gmail/calendar/drive/contacts/tasks/people/sheets)")
root.PersistentFlags().BoolVar(&flags.JSON, "json", flags.JSON, "Output JSON to stdout (best for scripting)")
root.PersistentFlags().BoolVar(&flags.Plain, "plain", flags.Plain, "Output stable, parseable text to stdout (TSV; no colors)")
root.PersistentFlags().BoolVar(&flags.Force, "force", false, "Skip confirmations for destructive commands")
root.PersistentFlags().BoolVar(&flags.NoInput, "no-input", false, "Never prompt; fail instead (useful for CI)")
root.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "Enable debug logging")
root.AddCommand(newAuthCmd(&flags))
root.AddCommand(newDriveCmd(&flags))
@ -119,12 +133,14 @@ func Execute(args []string) error {
root.AddCommand(newContactsCmd(&flags))
root.AddCommand(newTasksCmd(&flags))
root.AddCommand(newPeopleCmd(&flags))
root.AddCommand(newSheetsCmd(&flags))
root.AddCommand(newVersionCmd())
root.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
// pflag already includes helpful context ("unknown flag", "invalid argument", ...).
return newUsageError(err)
})
root.AddCommand(newCompletionCmd())
err := root.Execute()
if err == nil {

451
internal/cmd/sheets.go Normal file
View File

@ -0,0 +1,451 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/sheets/v4"
)
var newSheetsService = googleapi.NewSheets
// cleanRange removes shell escape sequences from range arguments.
// Some shells escape ! to \! (bash history expansion), which breaks Google Sheets API calls.
func cleanRange(r string) string {
return strings.ReplaceAll(r, `\!`, "!")
}
func newSheetsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "sheets",
Short: "Google Sheets",
}
cmd.AddCommand(newSheetsGetCmd(flags))
cmd.AddCommand(newSheetsUpdateCmd(flags))
cmd.AddCommand(newSheetsAppendCmd(flags))
cmd.AddCommand(newSheetsClearCmd(flags))
cmd.AddCommand(newSheetsMetadataCmd(flags))
cmd.AddCommand(newSheetsCreateCmd(flags))
return cmd
}
func newSheetsGetCmd(flags *rootFlags) *cobra.Command {
var majorDimension string
var valueRenderOption string
cmd := &cobra.Command{
Use: "get <spreadsheetId> <range>",
Short: "Get values from a range",
Long: "Get values from a specified range in a Google Sheets spreadsheet.\nExample: gog sheets get 1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms 'Sheet1!A1:B10'",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
call := svc.Spreadsheets.Values.Get(spreadsheetID, rangeSpec)
if majorDimension != "" {
call = call.MajorDimension(majorDimension)
}
if valueRenderOption != "" {
call = call.ValueRenderOption(valueRenderOption)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"range": resp.Range,
"values": resp.Values,
})
}
if len(resp.Values) == 0 {
u.Err().Println("No data found")
return nil
}
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, row := range resp.Values {
cells := make([]string, len(row))
for i, cell := range row {
cells[i] = fmt.Sprintf("%v", cell)
}
fmt.Fprintln(tw, strings.Join(cells, "\t"))
}
_ = tw.Flush()
return nil
},
}
cmd.Flags().StringVar(&majorDimension, "dimension", "", "Major dimension: ROWS or COLUMNS")
cmd.Flags().StringVar(&valueRenderOption, "render", "", "Value render option: FORMATTED_VALUE, UNFORMATTED_VALUE, or FORMULA")
return cmd
}
func newSheetsUpdateCmd(flags *rootFlags) *cobra.Command {
var valueInputOption string
var jsonValues string
cmd := &cobra.Command{
Use: "update <spreadsheetId> <range> [values...]",
Short: "Update values in a range",
Long: `Update values in a specified range.
Values can be provided as:
1. Command line args (comma-separated rows, pipe-separated cells):
gog sheets update ID 'A1' 'a|b|c,d|e|f' (2 rows, 3 cols each)
2. JSON via --json flag:
gog sheets update ID 'A1' --json '[["a","b"],["c","d"]]'
Examples:
gog sheets update 1BxiMVs... 'Sheet1!A1' 'Hello|World'
gog sheets update 1BxiMVs... 'Sheet1!A1:B2' --json '[["a","b"],["c","d"]]'`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
var values [][]interface{}
if jsonValues != "" {
if unmarshalErr := json.Unmarshal([]byte(jsonValues), &values); unmarshalErr != nil {
return fmt.Errorf("invalid JSON values: %w", unmarshalErr)
}
} else if len(args) > 2 {
// Parse comma-separated rows, pipe-separated cells
rawValues := strings.Join(args[2:], " ")
rows := strings.Split(rawValues, ",")
for _, row := range rows {
cells := strings.Split(strings.TrimSpace(row), "|")
rowData := make([]interface{}, len(cells))
for i, cell := range cells {
rowData[i] = strings.TrimSpace(cell)
}
values = append(values, rowData)
}
} else {
return fmt.Errorf("provide values as args or via --json")
}
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Update(spreadsheetID, rangeSpec, vr)
if valueInputOption == "" {
valueInputOption = "USER_ENTERED"
}
call = call.ValueInputOption(valueInputOption)
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"updatedRange": resp.UpdatedRange,
"updatedRows": resp.UpdatedRows,
"updatedColumns": resp.UpdatedColumns,
"updatedCells": resp.UpdatedCells,
})
}
u.Out().Printf("Updated %d cells in %s", resp.UpdatedCells, resp.UpdatedRange)
return nil
},
}
cmd.Flags().StringVar(&valueInputOption, "input", "USER_ENTERED", "Value input option: RAW or USER_ENTERED")
cmd.Flags().StringVar(&jsonValues, "json", "", "Values as JSON 2D array")
return cmd
}
func newSheetsAppendCmd(flags *rootFlags) *cobra.Command {
var valueInputOption string
var insertDataOption string
var jsonValues string
cmd := &cobra.Command{
Use: "append <spreadsheetId> <range> [values...]",
Short: "Append values to a range",
Long: `Append values after the last row with data in a range.
Values format same as 'update' command.
Examples:
gog sheets append 1BxiMVs... 'Sheet1!A:C' 'val1|val2|val3'
gog sheets append 1BxiMVs... 'Sheet1!A:C' --json '[["a","b","c"]]'`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
var values [][]interface{}
if jsonValues != "" {
if unmarshalErr := json.Unmarshal([]byte(jsonValues), &values); unmarshalErr != nil {
return fmt.Errorf("invalid JSON values: %w", unmarshalErr)
}
} else if len(args) > 2 {
rawValues := strings.Join(args[2:], " ")
rows := strings.Split(rawValues, ",")
for _, row := range rows {
cells := strings.Split(strings.TrimSpace(row), "|")
rowData := make([]interface{}, len(cells))
for i, cell := range cells {
rowData[i] = strings.TrimSpace(cell)
}
values = append(values, rowData)
}
} else {
return fmt.Errorf("provide values as args or via --json")
}
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
vr := &sheets.ValueRange{
Values: values,
}
call := svc.Spreadsheets.Values.Append(spreadsheetID, rangeSpec, vr)
if valueInputOption == "" {
valueInputOption = "USER_ENTERED"
}
call = call.ValueInputOption(valueInputOption)
if insertDataOption != "" {
call = call.InsertDataOption(insertDataOption)
}
resp, err := call.Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"updatedRange": resp.Updates.UpdatedRange,
"updatedRows": resp.Updates.UpdatedRows,
"updatedColumns": resp.Updates.UpdatedColumns,
"updatedCells": resp.Updates.UpdatedCells,
})
}
u.Out().Printf("Appended %d cells to %s", resp.Updates.UpdatedCells, resp.Updates.UpdatedRange)
return nil
},
}
cmd.Flags().StringVar(&valueInputOption, "input", "USER_ENTERED", "Value input option: RAW or USER_ENTERED")
cmd.Flags().StringVar(&insertDataOption, "insert", "", "Insert data option: OVERWRITE or INSERT_ROWS")
cmd.Flags().StringVar(&jsonValues, "json", "", "Values as JSON 2D array")
return cmd
}
func newSheetsClearCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "clear <spreadsheetId> <range>",
Short: "Clear values in a range",
Long: "Clear all values in a specified range (keeps formatting).",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
rangeSpec := cleanRange(args[1])
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Spreadsheets.Values.Clear(spreadsheetID, rangeSpec, &sheets.ClearValuesRequest{}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"clearedRange": resp.ClearedRange,
})
}
u.Out().Printf("Cleared %s", resp.ClearedRange)
return nil
},
}
}
func newSheetsMetadataCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "metadata <spreadsheetId>",
Short: "Get spreadsheet metadata",
Long: "Get metadata about a spreadsheet including title, sheets, and properties.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
spreadsheetID := args[0]
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
resp, err := svc.Spreadsheets.Get(spreadsheetID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"spreadsheetId": resp.SpreadsheetId,
"title": resp.Properties.Title,
"locale": resp.Properties.Locale,
"timeZone": resp.Properties.TimeZone,
"sheets": resp.Sheets,
})
}
u.Out().Printf("ID\t%s", resp.SpreadsheetId)
u.Out().Printf("Title\t%s", resp.Properties.Title)
u.Out().Printf("Locale\t%s", resp.Properties.Locale)
u.Out().Printf("TimeZone\t%s", resp.Properties.TimeZone)
u.Out().Printf("URL\t%s", resp.SpreadsheetUrl)
u.Out().Println("")
u.Out().Println("Sheets:")
tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTITLE\tROWS\tCOLS")
for _, sheet := range resp.Sheets {
props := sheet.Properties
fmt.Fprintf(tw, "%d\t%s\t%d\t%d\n",
props.SheetId,
props.Title,
props.GridProperties.RowCount,
props.GridProperties.ColumnCount,
)
}
_ = tw.Flush()
return nil
},
}
}
func newSheetsCreateCmd(flags *rootFlags) *cobra.Command {
var sheetNames string
cmd := &cobra.Command{
Use: "create <title>",
Short: "Create a new spreadsheet",
Long: `Create a new Google Sheets spreadsheet.
Examples:
gog sheets create "My Spreadsheet"
gog sheets create "Budget" --sheets "Income,Expenses,Summary"`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
title := args[0]
svc, err := newSheetsService(cmd.Context(), account)
if err != nil {
return err
}
spreadsheet := &sheets.Spreadsheet{
Properties: &sheets.SpreadsheetProperties{
Title: title,
},
}
if sheetNames != "" {
names := strings.Split(sheetNames, ",")
spreadsheet.Sheets = make([]*sheets.Sheet, len(names))
for i, name := range names {
spreadsheet.Sheets[i] = &sheets.Sheet{
Properties: &sheets.SheetProperties{
Title: strings.TrimSpace(name),
},
}
}
}
resp, err := svc.Spreadsheets.Create(spreadsheet).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"spreadsheetId": resp.SpreadsheetId,
"title": resp.Properties.Title,
"spreadsheetUrl": resp.SpreadsheetUrl,
})
}
u.Out().Printf("Created spreadsheet: %s", resp.Properties.Title)
u.Out().Printf("ID: %s", resp.SpreadsheetId)
u.Out().Printf("URL: %s", resp.SpreadsheetUrl)
return nil
},
}
cmd.Flags().StringVar(&sheetNames, "sheets", "", "Comma-separated sheet names to create")
return cmd
}

View File

@ -0,0 +1,74 @@
package googleapi
import (
"log/slog"
"sync"
"time"
)
const (
// CircuitBreakerThreshold is the number of consecutive failures to open the circuit
CircuitBreakerThreshold = 5
// CircuitBreakerResetTime is how long to wait before attempting to close the circuit
CircuitBreakerResetTime = 30 * time.Second
)
type CircuitBreaker struct {
mu sync.Mutex
failures int
lastFailure time.Time
open bool
}
func NewCircuitBreaker() *CircuitBreaker {
return &CircuitBreaker{}
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
wasOpen := cb.open
cb.failures = 0
cb.open = false
if wasOpen {
slog.Info("circuit breaker reset")
}
}
func (cb *CircuitBreaker) RecordFailure() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.failures++
cb.lastFailure = time.Now()
if cb.failures >= CircuitBreakerThreshold {
cb.open = true
slog.Warn("circuit breaker opened", "failures", cb.failures)
return true // circuit just opened
}
return false
}
func (cb *CircuitBreaker) IsOpen() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
if !cb.open {
return false
}
// Check if reset time has passed
if time.Since(cb.lastFailure) > CircuitBreakerResetTime {
cb.open = false
cb.failures = 0
slog.Info("circuit breaker attempting reset after timeout")
return false
}
return true
}
func (cb *CircuitBreaker) State() string {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.open {
return "open"
}
return "closed"
}

View File

@ -2,6 +2,8 @@ package googleapi
import (
"context"
"crypto/tls"
"log/slog"
"net/http"
"time"
@ -63,16 +65,34 @@ func tokenSourceForAccountScopes(ctx context.Context, serviceLabel string, email
}
func optionsForAccount(ctx context.Context, service googleauth.Service, email string) ([]option.ClientOption, error) {
slog.Debug("creating client options", "service", service, "email", email)
ts, err := tokenSourceForAccount(ctx, service, email)
if err != nil {
return nil, err
}
c := oauth2.NewClient(ctx, ts)
c.Timeout = defaultHTTPTimeout
baseTransport := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
// Wrap with retry logic for 429 and 5xx errors
retryTransport := NewRetryTransport(&oauth2.Transport{
Source: ts,
Base: baseTransport,
})
c := &http.Client{
Transport: retryTransport,
Timeout: defaultHTTPTimeout,
}
slog.Debug("client options created successfully", "service", service, "email", email)
return []option.ClientOption{option.WithHTTPClient(c)}, nil
}
func optionsForAccountScopes(ctx context.Context, serviceLabel string, email string, scopes []string) ([]option.ClientOption, error) {
slog.Debug("creating client options with custom scopes", "serviceLabel", serviceLabel, "email", email)
creds, err := readClientCredentials()
if err != nil {
return nil, err
@ -81,7 +101,21 @@ func optionsForAccountScopes(ctx context.Context, serviceLabel string, email str
if err != nil {
return nil, err
}
c := oauth2.NewClient(ctx, ts)
c.Timeout = defaultHTTPTimeout
baseTransport := &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
}
// Wrap with retry logic for 429 and 5xx errors
retryTransport := NewRetryTransport(&oauth2.Transport{
Source: ts,
Base: baseTransport,
})
c := &http.Client{
Transport: retryTransport,
Timeout: defaultHTTPTimeout,
}
slog.Debug("client options with custom scopes created successfully", "serviceLabel", serviceLabel, "email", email)
return []option.ClientOption{option.WithHTTPClient(c)}, nil
}

View File

@ -21,6 +21,8 @@ func (s *stubStore) Keys() ([]string, error) { return nil, nil }
func (s *stubStore) SetToken(string, secrets.Token) error { return nil }
func (s *stubStore) DeleteToken(string) error { return nil }
func (s *stubStore) ListTokens() ([]secrets.Token, error) { return nil, nil }
func (s *stubStore) GetDefaultAccount() (string, error) { return "", nil }
func (s *stubStore) SetDefaultAccount(string) error { return nil }
func (s *stubStore) GetToken(email string) (secrets.Token, error) {
s.lastEmail = email
if s.err != nil {

View File

@ -1,6 +1,10 @@
package googleapi
import "fmt"
import (
"errors"
"fmt"
"time"
)
type AuthRequiredError struct {
Service string
@ -15,3 +19,97 @@ func (e *AuthRequiredError) Error() string {
func (e *AuthRequiredError) Unwrap() error {
return e.Cause
}
// RateLimitError indicates rate limit was exceeded
type RateLimitError struct {
RetryAfter time.Duration
Retries int
}
func (e *RateLimitError) Error() string {
if e.RetryAfter > 0 {
return fmt.Sprintf("rate limit exceeded, retry after %s (attempted %d retries)", e.RetryAfter, e.Retries)
}
return fmt.Sprintf("rate limit exceeded after %d retries", e.Retries)
}
// CircuitBreakerError indicates the circuit breaker is open
type CircuitBreakerError struct{}
func (e *CircuitBreakerError) Error() string {
return "circuit breaker is open, too many recent failures - try again later"
}
// QuotaExceededError indicates API quota was exceeded
type QuotaExceededError struct {
Resource string
}
func (e *QuotaExceededError) Error() string {
if e.Resource != "" {
return fmt.Sprintf("API quota exceeded for %s", e.Resource)
}
return "API quota exceeded"
}
// NotFoundError indicates the requested resource was not found
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
if e.ID != "" {
return fmt.Sprintf("%s not found: %s", e.Resource, e.ID)
}
return fmt.Sprintf("%s not found", e.Resource)
}
// PermissionDeniedError indicates insufficient permissions
type PermissionDeniedError struct {
Resource string
Action string
}
func (e *PermissionDeniedError) Error() string {
if e.Action != "" {
return fmt.Sprintf("permission denied: cannot %s %s", e.Action, e.Resource)
}
return fmt.Sprintf("permission denied for %s", e.Resource)
}
// IsAuthRequiredError checks if the error is an auth required error
func IsAuthRequiredError(err error) bool {
var e *AuthRequiredError
return errors.As(err, &e)
}
// IsRateLimitError checks if the error is a rate limit error
func IsRateLimitError(err error) bool {
var e *RateLimitError
return errors.As(err, &e)
}
// IsCircuitBreakerError checks if the error is a circuit breaker error
func IsCircuitBreakerError(err error) bool {
var e *CircuitBreakerError
return errors.As(err, &e)
}
// IsQuotaExceededError checks if the error is a quota exceeded error
func IsQuotaExceededError(err error) bool {
var e *QuotaExceededError
return errors.As(err, &e)
}
// IsNotFoundError checks if the error is a not found error
func IsNotFoundError(err error) bool {
var e *NotFoundError
return errors.As(err, &e)
}
// IsPermissionDeniedError checks if the error is a permission denied error
func IsPermissionDeniedError(err error) bool {
var e *PermissionDeniedError
return errors.As(err, &e)
}

View File

@ -0,0 +1,14 @@
package googleapi
import "time"
const (
// MaxRateLimitRetries is the maximum number of retries on 429 responses.
MaxRateLimitRetries = 3
// RateLimitBaseDelay is the initial delay for rate limit exponential backoff.
RateLimitBaseDelay = 1 * time.Second
// Max5xxRetries is the maximum retries for server errors.
Max5xxRetries = 1
// ServerErrorRetryDelay is the delay before retrying on 5xx errors.
ServerErrorRetryDelay = 1 * time.Second
)

View File

@ -35,6 +35,9 @@ func TestNewServices_HappyPath(t *testing.T) {
if svc, err := NewCalendar(ctx, "a@b.com"); err != nil || svc == nil {
t.Fatalf("NewCalendar: %v", err)
}
if svc, err := NewSheets(ctx, "a@b.com"); err != nil || svc == nil {
t.Fatalf("NewSheets: %v", err)
}
if svc, err := NewPeopleContacts(ctx, "a@b.com"); err != nil || svc == nil {
t.Fatalf("NewPeopleContacts: %v", err)
}
@ -44,9 +47,6 @@ func TestNewServices_HappyPath(t *testing.T) {
if svc, err := NewPeopleDirectory(ctx, "a@b.com"); err != nil || svc == nil {
t.Fatalf("NewPeopleDirectory: %v", err)
}
if svc, err := NewTasks(ctx, "a@b.com"); err != nil || svc == nil {
t.Fatalf("NewTasks: %v", err)
}
}
func TestNewServices_AuthRequired(t *testing.T) {

View File

@ -0,0 +1,28 @@
package googleapi
import (
"context"
"log/slog"
"google.golang.org/api/sheets/v4"
"github.com/steipete/gogcli/internal/googleauth"
)
func NewSheets(ctx context.Context, email string) (*sheets.Service, error) {
slog.Debug("creating sheets service", "email", email)
opts, err := optionsForAccount(ctx, googleauth.ServiceSheets, email)
if err != nil {
return nil, err
}
svc, err := sheets.NewService(ctx, opts...)
if err != nil {
slog.Error("failed to create sheets service", "email", email, "error", err)
return nil, err
}
slog.Debug("sheets service created successfully", "email", email)
return svc, nil
}

View File

@ -0,0 +1,220 @@
package googleapi
import (
"context"
"io"
"log/slog"
"math/rand/v2"
"net/http"
"strconv"
"time"
)
// RetryTransport wraps an http.RoundTripper with retry logic for
// rate limits (429) and server errors (5xx).
type RetryTransport struct {
Base http.RoundTripper
MaxRetries429 int
MaxRetries5xx int
BaseDelay time.Duration
CircuitBreaker *CircuitBreaker
}
// NewRetryTransport creates a RetryTransport with sensible defaults.
func NewRetryTransport(base http.RoundTripper) *RetryTransport {
if base == nil {
base = http.DefaultTransport
}
return &RetryTransport{
Base: base,
MaxRetries429: MaxRateLimitRetries,
MaxRetries5xx: Max5xxRetries,
BaseDelay: RateLimitBaseDelay,
CircuitBreaker: NewCircuitBreaker(),
}
}
// RoundTrip implements http.RoundTripper with retry logic.
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.CircuitBreaker != nil && t.CircuitBreaker.IsOpen() {
return nil, &CircuitBreakerError{}
}
if err := ensureReplayableBody(req); err != nil {
return nil, err
}
var resp *http.Response
var err error
retries429 := 0
retries5xx := 0
for {
// Reset body for retry
if req.GetBody != nil {
if req.Body != nil {
_ = req.Body.Close()
}
req.Body, err = req.GetBody()
if err != nil {
return nil, err
}
}
resp, err = t.Base.RoundTrip(req)
if err != nil {
return nil, err
}
// Success
if resp.StatusCode < 400 {
if t.CircuitBreaker != nil {
t.CircuitBreaker.RecordSuccess()
}
return resp, nil
}
// Rate limit (429)
if resp.StatusCode == http.StatusTooManyRequests {
if retries429 >= t.MaxRetries429 {
return resp, nil // Return the 429 response after max retries
}
delay := t.calculateBackoff(retries429, resp)
slog.Debug("rate limited, retrying",
"delay", delay,
"attempt", retries429+1,
"max_retries", t.MaxRetries429)
drainAndClose(resp.Body)
if err := t.sleep(req.Context(), delay); err != nil {
return nil, err
}
retries429++
continue
}
// Server error (5xx)
if resp.StatusCode >= 500 {
if t.CircuitBreaker != nil {
t.CircuitBreaker.RecordFailure()
}
if retries5xx >= t.MaxRetries5xx {
return resp, nil
}
slog.Debug("server error, retrying",
"status", resp.StatusCode,
"attempt", retries5xx+1)
drainAndClose(resp.Body)
if err := t.sleep(req.Context(), ServerErrorRetryDelay); err != nil {
return nil, err
}
retries5xx++
continue
}
// Other errors (4xx except 429): don't retry
return resp, nil
}
}
func (t *RetryTransport) calculateBackoff(attempt int, resp *http.Response) time.Duration {
// Check Retry-After header
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil {
if seconds < 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
if t, err := http.ParseTime(retryAfter); err == nil {
d := time.Until(t)
if d < 0 {
return 0
}
return d
}
}
// Exponential backoff with jitter: 1s, 2s, 4s...
if t.BaseDelay <= 0 {
return 0
}
baseDelay := t.BaseDelay * time.Duration(1<<attempt)
if baseDelay <= 0 {
return 0
}
jitterRange := baseDelay / 2
if jitterRange <= 0 {
return baseDelay
}
jitter := time.Duration(rand.Int64N(int64(jitterRange)))
return baseDelay + jitter
}
func (t *RetryTransport) sleep(ctx context.Context, d time.Duration) error {
if d <= 0 {
return nil
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// bytesReader is a simple bytes.Reader replacement to avoid import
type bytesReader struct {
data []byte
pos int
}
func newBytesReader(data []byte) *bytesReader {
return &bytesReader{data: data}
}
func (r *bytesReader) Read(p []byte) (n int, err error) {
if r.pos >= len(r.data) {
return 0, io.EOF
}
n = copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}
func ensureReplayableBody(req *http.Request) error {
if req == nil || req.Body == nil || req.GetBody != nil {
return nil
}
bodyBytes, err := io.ReadAll(req.Body)
if err != nil {
return err
}
_ = req.Body.Close()
req.GetBody = func() (io.ReadCloser, error) {
return io.NopCloser(newBytesReader(bodyBytes)), nil
}
req.Body = io.NopCloser(newBytesReader(bodyBytes))
return nil
}
func drainAndClose(body io.ReadCloser) {
if body == nil {
return
}
_, _ = io.Copy(io.Discard, io.LimitReader(body, 1<<20))
_ = body.Close()
}

View File

@ -0,0 +1,306 @@
package googleapi
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
// mockTransport implements http.RoundTripper for testing
type mockTransport struct {
responses []*http.Response
errors []error
calls int
}
func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
idx := m.calls
m.calls++
if idx < len(m.errors) && m.errors[idx] != nil {
return nil, m.errors[idx]
}
if idx < len(m.responses) {
return m.responses[idx], nil
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(strings.NewReader("")),
}, nil
}
func TestRetryTransport_Success(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))},
},
}
rt := NewRetryTransport(mock)
req, _ := http.NewRequest("GET", "https://example.com", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if mock.calls != 1 {
t.Errorf("expected 1 call, got %d", mock.calls)
}
}
func TestRetryTransport_RateLimit_Retry(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited"))},
{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))},
},
}
rt := NewRetryTransport(mock)
rt.BaseDelay = 10 * time.Millisecond // Speed up test
req, _ := http.NewRequest("GET", "https://example.com", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retry, got %d", resp.StatusCode)
}
if mock.calls != 2 {
t.Errorf("expected 2 calls (1 retry), got %d", mock.calls)
}
}
func TestRetryTransport_RateLimit_MaxRetries(t *testing.T) {
// All responses are 429
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited"))},
{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited"))},
{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited"))},
{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited"))},
},
}
rt := NewRetryTransport(mock)
rt.BaseDelay = 1 * time.Millisecond
rt.MaxRetries429 = 2
req, _ := http.NewRequest("GET", "https://example.com", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 429 {
t.Errorf("expected 429 after max retries, got %d", resp.StatusCode)
}
// 1 initial + 2 retries = 3 total
if mock.calls != 3 {
t.Errorf("expected 3 calls, got %d", mock.calls)
}
}
func TestRetryTransport_ServerError_Retry(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 503, Body: io.NopCloser(strings.NewReader("service unavailable"))},
{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))},
},
}
rt := NewRetryTransport(mock)
req, _ := http.NewRequest("GET", "https://example.com", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retry, got %d", resp.StatusCode)
}
if mock.calls != 2 {
t.Errorf("expected 2 calls, got %d", mock.calls)
}
}
func TestRetryTransport_ClientError_NoRetry(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 404, Body: io.NopCloser(strings.NewReader("not found"))},
},
}
rt := NewRetryTransport(mock)
req, _ := http.NewRequest("GET", "https://example.com", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 404 {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
if mock.calls != 1 {
t.Errorf("expected 1 call (no retry for 4xx), got %d", mock.calls)
}
}
func TestRetryTransport_ContextCanceled(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited"))},
},
}
rt := NewRetryTransport(mock)
rt.BaseDelay = 1 * time.Second // Long delay
ctx, cancel := context.WithCancel(context.Background())
req, _ := http.NewRequestWithContext(ctx, "GET", "https://example.com", nil)
// Cancel context during the backoff
go func() {
time.Sleep(10 * time.Millisecond)
cancel()
}()
_, err := rt.RoundTrip(req)
if err != context.Canceled {
t.Errorf("expected context.Canceled, got %v", err)
}
}
func TestRetryTransport_CircuitBreakerOpen(t *testing.T) {
mock := &mockTransport{}
rt := NewRetryTransport(mock)
// Force circuit breaker open
for i := 0; i < CircuitBreakerThreshold; i++ {
rt.CircuitBreaker.RecordFailure()
}
req, _ := http.NewRequest("GET", "https://example.com", nil)
_, err := rt.RoundTrip(req)
if err == nil {
t.Fatal("expected error when circuit breaker is open")
}
if !IsCircuitBreakerError(err) {
t.Errorf("expected CircuitBreakerError, got %T", err)
}
if mock.calls != 0 {
t.Errorf("expected 0 calls when circuit open, got %d", mock.calls)
}
}
func TestRetryTransport_CircuitBreakerReset(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))},
},
}
rt := NewRetryTransport(mock)
// Record failures but not enough to open
for i := 0; i < CircuitBreakerThreshold-1; i++ {
rt.CircuitBreaker.RecordFailure()
}
req, _ := http.NewRequest("GET", "https://example.com", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
// After success, failures should be reset
if rt.CircuitBreaker.failures != 0 {
t.Errorf("expected failures reset to 0, got %d", rt.CircuitBreaker.failures)
}
}
func TestRetryTransport_RetryAfterHeader(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{
StatusCode: 429,
Header: http.Header{"Retry-After": []string{"1"}},
Body: io.NopCloser(strings.NewReader("rate limited")),
},
{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))},
},
}
rt := NewRetryTransport(mock)
rt.BaseDelay = 1 * time.Hour // Would be very long without Retry-After
start := time.Now()
req, _ := http.NewRequest("GET", "https://example.com", nil)
resp, err := rt.RoundTrip(req)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
// Should have waited ~1 second based on Retry-After header
if elapsed < 900*time.Millisecond || elapsed > 2*time.Second {
t.Errorf("expected ~1s delay from Retry-After, got %v", elapsed)
}
}
func TestRetryTransport_CalculateBackoff_NoPanic(t *testing.T) {
rt := NewRetryTransport(&mockTransport{})
resp := &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{},
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("calculateBackoff panicked: %v", r)
}
}()
rt.BaseDelay = 0
_ = rt.calculateBackoff(0, resp)
rt.BaseDelay = 1 * time.Nanosecond
_ = rt.calculateBackoff(0, resp)
}
func TestRetryTransport_WithRequestBody(t *testing.T) {
mock := &mockTransport{
responses: []*http.Response{
{StatusCode: 429, Body: io.NopCloser(strings.NewReader("rate limited"))},
{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))},
},
}
rt := NewRetryTransport(mock)
rt.BaseDelay = 1 * time.Millisecond
body := strings.NewReader("request body")
req, _ := http.NewRequest("POST", "https://example.com", body)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retry, got %d", resp.StatusCode)
}
if mock.calls != 2 {
t.Errorf("expected 2 calls, got %d", mock.calls)
}
}

View File

@ -0,0 +1,399 @@
package googleauth
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"os"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/steipete/gogcli/internal/secrets"
)
// AccountInfo represents an account for the UI
type AccountInfo struct {
Email string `json:"email"`
Services []string `json:"services"`
IsDefault bool `json:"isDefault"`
}
// ManageServerOptions configures the accounts management server
type ManageServerOptions struct {
Timeout time.Duration
Services []Service
ForceConsent bool
}
// ManageServer handles the accounts management UI
type ManageServer struct {
opts ManageServerOptions
csrfToken string
listener net.Listener
server *http.Server
store secrets.Store
oauthState string
resultCh chan error
}
// StartManageServer starts the accounts management server and opens browser
func StartManageServer(ctx context.Context, opts ManageServerOptions) error {
if opts.Timeout <= 0 {
opts.Timeout = 10 * time.Minute
}
store, err := secrets.OpenDefault()
if err != nil {
return fmt.Errorf("failed to open secrets store: %w", err)
}
csrfToken, err := generateCSRFToken()
if err != nil {
return fmt.Errorf("failed to generate CSRF token: %w", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("failed to start listener: %w", err)
}
ms := &ManageServer{
opts: opts,
csrfToken: csrfToken,
listener: ln,
store: store,
resultCh: make(chan error, 1),
}
mux := http.NewServeMux()
mux.HandleFunc("/", ms.handleAccountsPage)
mux.HandleFunc("/accounts", ms.handleListAccounts)
mux.HandleFunc("/auth/start", ms.handleAuthStart)
mux.HandleFunc("/oauth2/callback", ms.handleOAuthCallback)
mux.HandleFunc("/set-default", ms.handleSetDefault)
mux.HandleFunc("/remove-account", ms.handleRemoveAccount)
ms.server = &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
go func() {
<-ctx.Done()
_ = ms.server.Close()
}()
go func() {
if err := ms.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
select {
case ms.resultCh <- err:
default:
}
}
}()
port := ln.Addr().(*net.TCPAddr).Port
url := fmt.Sprintf("http://127.0.0.1:%d", port)
fmt.Fprintln(os.Stderr, "Opening accounts manager in browser...")
fmt.Fprintln(os.Stderr, "If the browser doesn't open, visit:", url)
_ = openBrowser(url)
select {
case err := <-ms.resultCh:
return err
case <-ctx.Done():
_ = ms.server.Close()
return nil
}
}
func (ms *ManageServer) handleAccountsPage(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl, err := template.New("accounts").Parse(accountsTemplate)
if err != nil {
http.Error(w, "Failed to render page", http.StatusInternalServerError)
return
}
data := struct {
CSRFToken string
}{
CSRFToken: ms.csrfToken,
}
_ = tmpl.Execute(w, data)
}
func (ms *ManageServer) handleListAccounts(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
tokens, err := ms.store.ListTokens()
if err != nil {
writeJSONError(w, "Failed to list accounts", http.StatusInternalServerError)
return
}
defaultEmail, _ := ms.store.GetDefaultAccount()
accounts := make([]AccountInfo, 0, len(tokens))
for i, t := range tokens {
isDefault := false
if defaultEmail != "" {
isDefault = t.Email == defaultEmail
} else {
isDefault = i == 0 // First account is default if none set
}
accounts = append(accounts, AccountInfo{
Email: t.Email,
Services: t.Services,
IsDefault: isDefault,
})
}
writeJSON(w, map[string]any{"accounts": accounts})
}
func (ms *ManageServer) handleAuthStart(w http.ResponseWriter, r *http.Request) {
creds, err := readClientCredentials()
if err != nil {
http.Error(w, "OAuth credentials not configured. Run: gog auth credentials <file>", http.StatusInternalServerError)
return
}
state, err := randomStateFn()
if err != nil {
http.Error(w, "Failed to generate state", http.StatusInternalServerError)
return
}
ms.oauthState = state
services := ms.opts.Services
if len(services) == 0 {
services = AllServices()
}
scopes, err := ScopesForServices(services)
if err != nil {
http.Error(w, "Failed to get scopes", http.StatusInternalServerError)
return
}
port := ms.listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oauth2/callback", port)
cfg := oauth2.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: redirectURI,
Scopes: scopes,
}
authURL := cfg.AuthCodeURL(state, authURLParams(ms.opts.ForceConsent)...)
http.Redirect(w, r, authURL, http.StatusFound)
}
func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if q.Get("error") != "" {
w.WriteHeader(http.StatusOK)
renderCancelledPage(w)
return
}
if q.Get("state") != ms.oauthState {
w.WriteHeader(http.StatusBadRequest)
renderErrorPage(w, "State mismatch - possible CSRF attack. Please try again.")
return
}
code := q.Get("code")
if code == "" {
w.WriteHeader(http.StatusBadRequest)
renderErrorPage(w, "Missing authorization code. Please try again.")
return
}
creds, err := readClientCredentials()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
renderErrorPage(w, "Failed to read credentials")
return
}
services := ms.opts.Services
if len(services) == 0 {
services = AllServices()
}
scopes, _ := ScopesForServices(services)
port := ms.listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oauth2/callback", port)
cfg := oauth2.Config{
ClientID: creds.ClientID,
ClientSecret: creds.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: redirectURI,
Scopes: scopes,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
tok, err := cfg.Exchange(ctx, code)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
renderErrorPage(w, "Failed to exchange code for token: "+err.Error())
return
}
if tok.RefreshToken == "" {
w.WriteHeader(http.StatusBadRequest)
renderErrorPage(w, "No refresh token received. Try again with force-consent.")
return
}
// Get user email from token
email := q.Get("email")
if email == "" {
// Try to get email from ID token or use a placeholder
email = "user@gmail.com"
}
// Store the token
serviceNames := make([]string, 0, len(services))
for _, svc := range services {
serviceNames = append(serviceNames, string(svc))
}
if err := ms.store.SetToken(email, secrets.Token{
Email: email,
Services: serviceNames,
Scopes: scopes,
RefreshToken: tok.RefreshToken,
}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
renderErrorPage(w, "Failed to store token: "+err.Error())
return
}
// Render success page with the new template
w.WriteHeader(http.StatusOK)
renderSuccessPageNew(w, email, serviceNames)
}
func (ms *ManageServer) handleSetDefault(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Header.Get("X-CSRF-Token") != ms.csrfToken {
writeJSONError(w, "Invalid CSRF token", http.StatusForbidden)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ms.store.SetDefaultAccount(req.Email); err != nil {
writeJSONError(w, "Failed to set default account", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"success": true})
}
func (ms *ManageServer) handleRemoveAccount(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if r.Header.Get("X-CSRF-Token") != ms.csrfToken {
writeJSONError(w, "Invalid CSRF token", http.StatusForbidden)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, "Invalid request", http.StatusBadRequest)
return
}
if err := ms.store.DeleteToken(req.Email); err != nil {
writeJSONError(w, "Failed to remove account", http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"success": true})
}
func generateCSRFToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func writeJSON(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
}
func writeJSONError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": msg})
}
// renderSuccessPageNew renders the new success template with email and services
func renderSuccessPageNew(w http.ResponseWriter, email string, services []string) {
tmpl, err := template.New("success").Parse(successTemplateNew)
if err != nil {
_, _ = w.Write([]byte("Success! You can close this window."))
return
}
data := struct {
Email string
Services []string
}{
Email: email,
Services: services,
}
_ = tmpl.Execute(w, data)
}

View File

@ -7,6 +7,7 @@ import (
"encoding/base64"
"errors"
"fmt"
"html/template"
"net"
"net/http"
"net/url"
@ -99,7 +100,7 @@ func Authorize(ctx context.Context, opts AuthorizeOptions) (string, error) {
if err != nil {
return "", err
}
defer ln.Close()
defer func() { _ = ln.Close() }()
port := ln.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/oauth2/callback", port)
@ -122,13 +123,15 @@ func Authorize(ctx context.Context, opts AuthorizeOptions) (string, error) {
return
}
q := r.URL.Query()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if q.Get("error") != "" {
select {
case errCh <- fmt.Errorf("authorization error: %s", q.Get("error")):
default:
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Authorization cancelled. You can close this window."))
renderCancelledPage(w)
return
}
if q.Get("state") != state {
@ -137,7 +140,7 @@ func Authorize(ctx context.Context, opts AuthorizeOptions) (string, error) {
default:
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("State mismatch. You can close this window."))
renderErrorPage(w, "State mismatch - possible CSRF attack. Please try again.")
return
}
code := q.Get("code")
@ -147,7 +150,7 @@ func Authorize(ctx context.Context, opts AuthorizeOptions) (string, error) {
default:
}
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("Missing code. You can close this window."))
renderErrorPage(w, "Missing authorization code. Please try again.")
return
}
select {
@ -155,7 +158,7 @@ func Authorize(ctx context.Context, opts AuthorizeOptions) (string, error) {
default:
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("Success! You can close this window."))
renderSuccessPage(w)
}),
}
@ -230,3 +233,33 @@ func extractCodeAndState(rawURL string) (code string, state string, err error) {
}
return code, q.Get("state"), nil
}
// renderSuccessPage renders the success HTML template
func renderSuccessPage(w http.ResponseWriter) {
tmpl, err := template.New("success").Parse(successTemplate)
if err != nil {
_, _ = w.Write([]byte("Success! You can close this window."))
return
}
_ = tmpl.Execute(w, nil)
}
// renderErrorPage renders the error HTML template with the given message
func renderErrorPage(w http.ResponseWriter, errorMsg string) {
tmpl, err := template.New("error").Parse(errorTemplate)
if err != nil {
_, _ = w.Write([]byte("Error: " + errorMsg))
return
}
_ = tmpl.Execute(w, struct{ Error string }{Error: errorMsg})
}
// renderCancelledPage renders the cancelled HTML template
func renderCancelledPage(w http.ResponseWriter) {
tmpl, err := template.New("cancelled").Parse(cancelledTemplate)
if err != nil {
_, _ = w.Write([]byte("Authorization cancelled. You can close this window."))
return
}
_ = tmpl.Execute(w, nil)
}

View File

@ -16,19 +16,20 @@ const (
ServiceContacts Service = "contacts"
ServiceTasks Service = "tasks"
ServicePeople Service = "people"
ServiceSheets Service = "sheets"
)
func ParseService(s string) (Service, error) {
switch Service(strings.ToLower(strings.TrimSpace(s))) {
case ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts, ServiceTasks, ServicePeople:
case ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets:
return Service(strings.ToLower(strings.TrimSpace(s))), nil
default:
return "", fmt.Errorf("unknown service %q (expected gmail|calendar|drive|contacts|tasks|people)", s)
return "", fmt.Errorf("unknown service %q (expected gmail|calendar|drive|contacts|tasks|people|sheets)", s)
}
}
func AllServices() []Service {
return []Service{ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts, ServiceTasks, ServicePeople}
return []Service{ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets}
}
func Scopes(service Service) ([]string, error) {
@ -50,6 +51,8 @@ func Scopes(service Service) ([]string, error) {
case ServicePeople:
// Needed for "people/me" requests.
return []string{"profile"}, nil
case ServiceSheets:
return []string{"https://www.googleapis.com/auth/spreadsheets"}, nil
default:
return nil, errors.New("unknown service")
}

View File

@ -53,14 +53,14 @@ func TestExtractCodeAndState_Errors(t *testing.T) {
func TestAllServices(t *testing.T) {
svcs := AllServices()
if len(svcs) != 6 {
if len(svcs) != 7 {
t.Fatalf("unexpected: %v", svcs)
}
seen := make(map[Service]bool)
for _, s := range svcs {
seen[s] = true
}
for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts, ServiceTasks, ServicePeople} {
for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceDrive, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets} {
if !seen[want] {
t.Fatalf("missing %q", want)
}

View File

@ -0,0 +1,848 @@
package googleauth
// successTemplate renders after successful OAuth authorization
const successTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connected - gog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #0a0a0f;
--bg-card: #111118;
--bg-input: #18181f;
--border: #1f1f2e;
--text: #e8e8ed;
--text-muted: #8888a0;
--text-dim: #4a4a5a;
--google-blue: #4285F4;
--google-red: #EA4335;
--google-yellow: #FBBC05;
--google-green: #34A853;
--success: #34A853;
--success-glow: rgba(52, 168, 83, 0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DM Sans', -apple-system, sans-serif;
background: var(--bg-deep);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
position: relative;
overflow: hidden;
}
/* Subtle grid pattern */
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-image:
linear-gradient(rgba(66, 133, 244, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(66, 133, 244, 0.015) 1px, transparent 1px);
background-size: 80px 80px;
pointer-events: none;
}
/* Animated Google-colored gradient orbs */
.orb {
position: fixed;
border-radius: 50%;
filter: blur(120px);
opacity: 0.4;
pointer-events: none;
animation: orbDrift 25s ease-in-out infinite;
}
.orb-blue {
width: 600px;
height: 600px;
background: var(--google-blue);
top: -20%;
left: -10%;
animation-delay: 0s;
}
.orb-red {
width: 500px;
height: 500px;
background: var(--google-red);
top: 60%;
right: -15%;
animation-delay: -6s;
}
.orb-yellow {
width: 400px;
height: 400px;
background: var(--google-yellow);
bottom: -10%;
left: 30%;
animation-delay: -12s;
}
.orb-green {
width: 450px;
height: 450px;
background: var(--google-green);
top: 20%;
right: 20%;
animation-delay: -18s;
}
@keyframes orbDrift {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(30px, -20px) scale(1.05); }
50% { transform: translate(-20px, 30px) scale(0.95); }
75% { transform: translate(-30px, -10px) scale(1.02); }
}
.container {
width: 100%;
max-width: 540px;
position: relative;
z-index: 1;
text-align: center;
}
/* Google "G" logo with colors */
.google-logo {
width: 72px;
height: 72px;
margin: 0 auto 2rem;
animation: logoReveal 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
filter: drop-shadow(0 8px 32px rgba(66, 133, 244, 0.25));
}
@keyframes logoReveal {
from { transform: scale(0) rotate(-180deg); opacity: 0; }
to { transform: scale(1) rotate(0deg); opacity: 1; }
}
/* Success checkmark ring */
.success-ring {
position: absolute;
top: -12px;
right: -12px;
width: 32px;
height: 32px;
background: var(--success);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: ringPop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.5s both;
box-shadow: 0 4px 16px rgba(52, 168, 83, 0.4);
}
.success-ring svg {
width: 18px;
height: 18px;
stroke: white;
stroke-width: 3;
fill: none;
}
@keyframes ringPop {
from { transform: scale(0); }
to { transform: scale(1); }
}
.logo-container {
position: relative;
display: inline-block;
}
h1 {
font-size: 2.25rem;
font-weight: 700;
letter-spacing: -0.03em;
margin-bottom: 0.625rem;
animation: fadeSlideUp 0.5s ease 0.2s both;
background: linear-gradient(135deg, var(--text) 0%, var(--text-muted) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
color: var(--text-muted);
font-size: 1.0625rem;
margin-bottom: 2.5rem;
animation: fadeSlideUp 0.5s ease 0.3s both;
}
.subtitle strong {
color: var(--google-blue);
font-weight: 600;
}
@keyframes fadeSlideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* Terminal window */
.terminal {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
text-align: left;
animation: fadeSlideUp 0.5s ease 0.4s both;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.02) inset;
}
.terminal-bar {
background: var(--bg-input);
padding: 0.875rem 1.125rem;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 1px solid var(--border);
}
.terminal-dots {
display: flex;
gap: 6px;
}
.terminal-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.terminal-dot.close { background: #ff5f57; }
.terminal-dot.minimize { background: #febc2e; }
.terminal-dot.maximize { background: #28c840; }
.terminal-title {
flex: 1;
text-align: center;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: var(--text-dim);
margin-right: 48px;
}
.terminal-body {
padding: 1.5rem;
}
.terminal-line {
display: flex;
align-items: center;
gap: 0.625rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
margin-bottom: 0.875rem;
line-height: 1.5;
}
.terminal-line:last-child {
margin-bottom: 0;
}
.terminal-prompt {
color: var(--google-blue);
user-select: none;
font-weight: 500;
}
.terminal-cmd {
color: var(--text);
}
.terminal-flag {
color: var(--google-yellow);
}
.terminal-arg {
color: var(--google-green);
}
.terminal-output {
color: var(--text-dim);
padding-left: 1.125rem;
margin-top: -0.5rem;
margin-bottom: 0.875rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8125rem;
}
.terminal-output.success {
color: var(--success);
}
/* Blinking cursor - 1.2s interval as requested */
.terminal-cursor {
display: inline-block;
width: 10px;
height: 20px;
background: var(--google-blue);
animation: cursorBlink 1.2s step-end infinite;
margin-left: 2px;
vertical-align: middle;
border-radius: 1px;
}
@keyframes cursorBlink {
0%, 50% { opacity: 1; }
50.01%, 100% { opacity: 0; }
}
/* Info card */
.info-card {
margin-top: 2rem;
padding: 1.25rem 1.5rem;
background: rgba(66, 133, 244, 0.06);
border: 1px solid rgba(66, 133, 244, 0.12);
border-radius: 12px;
animation: fadeSlideUp 0.5s ease 0.5s both;
text-align: left;
display: flex;
gap: 1rem;
align-items: flex-start;
}
.info-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: rgba(66, 133, 244, 0.1);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.info-icon svg {
width: 20px;
height: 20px;
stroke: var(--google-blue);
}
.info-content h3 {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
margin-bottom: 0.25rem;
}
.info-content p {
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
}
.info-content code {
font-family: 'JetBrains Mono', monospace;
background: rgba(66, 133, 244, 0.1);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.8125rem;
color: var(--google-blue);
}
.footer {
margin-top: 2rem;
font-size: 0.8125rem;
color: var(--text-dim);
animation: fadeSlideUp 0.5s ease 0.6s both;
}
</style>
</head>
<body>
<div class="orb orb-blue"></div>
<div class="orb orb-red"></div>
<div class="orb orb-yellow"></div>
<div class="orb orb-green"></div>
<div class="container">
<div class="logo-container">
<svg class="google-logo" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
<div class="success-ring">
<svg viewBox="0 0 24 24">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
</div>
<h1>You're connected</h1>
<p class="subtitle">gog is now authorized to access <strong>Google Workspace</strong></p>
<div class="terminal">
<div class="terminal-bar">
<div class="terminal-dots">
<span class="terminal-dot close"></span>
<span class="terminal-dot minimize"></span>
<span class="terminal-dot maximize"></span>
</div>
<span class="terminal-title">Terminal</span>
</div>
<div class="terminal-body">
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-cmd">gog</span>
<span class="terminal-arg">calendar</span>
<span class="terminal-arg">list</span>
</div>
<div class="terminal-output success">Fetching calendars...</div>
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-cmd">gog</span>
<span class="terminal-arg">gmail</span>
<span class="terminal-arg">search</span>
<span class="terminal-flag">--query</span>
<span class="terminal-cmd">"is:unread"</span>
</div>
<div class="terminal-output success">Found 12 messages</div>
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-cursor"></span>
</div>
</div>
</div>
<div class="info-card">
<div class="info-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</div>
<div class="info-content">
<h3>Return to your terminal</h3>
<p>You can close this window. Run <code>gog --help</code> to see available commands.</p>
</div>
</div>
<p class="footer">This window will close automatically.</p>
</div>
</body>
</html>`
// errorTemplate renders when OAuth authorization fails
const errorTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authorization Failed - gog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #0a0a0f;
--bg-card: #111118;
--bg-input: #18181f;
--border: #1f1f2e;
--text: #e8e8ed;
--text-muted: #8888a0;
--text-dim: #4a4a5a;
--google-blue: #4285F4;
--google-red: #EA4335;
--error: #EA4335;
--error-glow: rgba(234, 67, 53, 0.15);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DM Sans', -apple-system, sans-serif;
background: var(--bg-deep);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
position: relative;
overflow: hidden;
}
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-image:
linear-gradient(rgba(234, 67, 53, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(234, 67, 53, 0.02) 1px, transparent 1px);
background-size: 80px 80px;
pointer-events: none;
}
.orb {
position: fixed;
border-radius: 50%;
filter: blur(120px);
opacity: 0.35;
pointer-events: none;
}
.orb-red {
width: 600px;
height: 600px;
background: var(--google-red);
top: -20%;
right: -10%;
animation: orbPulse 4s ease-in-out infinite;
}
@keyframes orbPulse {
0%, 100% { opacity: 0.35; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.1); }
}
.container {
width: 100%;
max-width: 480px;
position: relative;
z-index: 1;
text-align: center;
}
.error-icon {
width: 80px;
height: 80px;
margin: 0 auto 2rem;
background: var(--error-glow);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: iconShake 0.5s ease 0.2s both;
box-shadow: 0 8px 32px rgba(234, 67, 53, 0.2);
}
.error-icon svg {
width: 40px;
height: 40px;
stroke: var(--error);
stroke-width: 2;
}
@keyframes iconShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
h1 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.03em;
margin-bottom: 0.625rem;
color: var(--text);
animation: fadeIn 0.5s ease 0.3s both;
}
.subtitle {
color: var(--text-muted);
font-size: 1rem;
margin-bottom: 2rem;
animation: fadeIn 0.5s ease 0.4s both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.error-card {
background: var(--bg-card);
border: 1px solid rgba(234, 67, 53, 0.2);
border-radius: 12px;
padding: 1.5rem;
text-align: left;
animation: fadeIn 0.5s ease 0.5s both;
}
.error-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--error);
margin-bottom: 0.5rem;
}
.error-message {
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
color: var(--text);
word-break: break-word;
line-height: 1.6;
}
.help-section {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
text-align: left;
animation: fadeIn 0.5s ease 0.6s both;
}
.help-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
margin-bottom: 1rem;
}
.help-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.help-item:last-child {
margin-bottom: 0;
}
.help-num {
flex-shrink: 0;
width: 20px;
height: 20px;
background: var(--bg-input);
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-family: 'JetBrains Mono', monospace;
font-size: 0.6875rem;
color: var(--text-dim);
}
.help-item code {
font-family: 'JetBrains Mono', monospace;
background: var(--bg-input);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.8125rem;
color: var(--google-blue);
}
.footer {
margin-top: 2rem;
font-size: 0.8125rem;
color: var(--text-dim);
animation: fadeIn 0.5s ease 0.7s both;
}
</style>
</head>
<body>
<div class="orb orb-red"></div>
<div class="container">
<div class="error-icon">
<svg viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="15" y1="9" x2="9" y2="15"></line>
<line x1="9" y1="9" x2="15" y2="15"></line>
</svg>
</div>
<h1>Authorization failed</h1>
<p class="subtitle">Unable to connect to your Google account</p>
<div class="error-card">
<div class="error-label">Error</div>
<div class="error-message">{{.Error}}</div>
</div>
<div class="help-section">
<div class="help-title">Try again</div>
<div class="help-item">
<span class="help-num">1</span>
<span>Close this window and return to your terminal</span>
</div>
<div class="help-item">
<span class="help-num">2</span>
<span>Run <code>gog auth add you@gmail.com</code> to restart</span>
</div>
<div class="help-item">
<span class="help-num">3</span>
<span>If the issue persists, try <code>gog auth add --force-consent</code></span>
</div>
</div>
<p class="footer">You can close this window.</p>
</div>
</body>
</html>`
// cancelledTemplate renders when user cancels the OAuth flow
const cancelledTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cancelled - gog</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #0a0a0f;
--bg-card: #111118;
--bg-input: #18181f;
--border: #1f1f2e;
--text: #e8e8ed;
--text-muted: #8888a0;
--text-dim: #4a4a5a;
--google-blue: #4285F4;
--google-yellow: #FBBC05;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'DM Sans', -apple-system, sans-serif;
background: var(--bg-deep);
color: var(--text);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-image:
linear-gradient(rgba(251, 188, 5, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(251, 188, 5, 0.015) 1px, transparent 1px);
background-size: 80px 80px;
pointer-events: none;
}
.orb {
position: fixed;
width: 500px;
height: 500px;
background: var(--google-yellow);
border-radius: 50%;
filter: blur(120px);
opacity: 0.25;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.container {
width: 100%;
max-width: 420px;
position: relative;
z-index: 1;
text-align: center;
}
.icon {
width: 72px;
height: 72px;
margin: 0 auto 2rem;
background: rgba(251, 188, 5, 0.1);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.5s ease both;
}
.icon svg {
width: 32px;
height: 32px;
stroke: var(--google-yellow);
stroke-width: 2;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.9); }
to { opacity: 1; transform: scale(1); }
}
h1 {
font-size: 1.75rem;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
animation: fadeSlide 0.5s ease 0.1s both;
}
.subtitle {
color: var(--text-muted);
font-size: 1rem;
margin-bottom: 2rem;
animation: fadeSlide 0.5s ease 0.2s both;
}
@keyframes fadeSlide {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.action-hint {
font-size: 0.875rem;
color: var(--text-dim);
animation: fadeSlide 0.5s ease 0.3s both;
}
.action-hint code {
font-family: 'JetBrains Mono', monospace;
background: var(--bg-input);
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.8125rem;
color: var(--google-blue);
}
</style>
</head>
<body>
<div class="orb"></div>
<div class="container">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
</div>
<h1>Authorization cancelled</h1>
<p class="subtitle">No changes were made to your account</p>
<p class="action-hint">Run <code>gog auth add you@gmail.com</code> when you're ready to try again.</p>
</div>
</body>
</html>`

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ package secrets
import (
"encoding/json"
"errors"
"fmt"
"os"
"runtime"
@ -19,6 +20,8 @@ type Store interface {
GetToken(email string) (Token, error)
DeleteToken(email string) error
ListTokens() ([]Token, error)
GetDefaultAccount() (string, error)
SetDefaultAccount(email string) error
}
type KeyringStore struct {
@ -182,3 +185,27 @@ func tokenKey(email string) string {
func normalize(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
const defaultAccountKey = "default_account"
func (s *KeyringStore) GetDefaultAccount() (string, error) {
it, err := s.ring.Get(defaultAccountKey)
if err != nil {
if errors.Is(err, keyring.ErrKeyNotFound) {
return "", nil
}
return "", err
}
return string(it.Data), nil
}
func (s *KeyringStore) SetDefaultAccount(email string) error {
email = normalize(email)
if email == "" {
return fmt.Errorf("missing email")
}
return s.ring.Set(keyring.Item{
Key: defaultAccountKey,
Data: []byte(email),
})
}

View File

@ -1,26 +0,0 @@
package secrets
import (
"strings"
"testing"
)
func TestFileKeyringPasswordFuncFrom_UsesEnvPassword(t *testing.T) {
prompt := fileKeyringPasswordFuncFrom("pw", false)
got, err := prompt("ignored")
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
if got != "pw" {
t.Fatalf("expected password, got: %q", got)
}
}
func TestFileKeyringPasswordFuncFrom_NoTTYErrors(t *testing.T) {
prompt := fileKeyringPasswordFuncFrom("", false)
if _, err := prompt("ignored"); err == nil {
t.Fatalf("expected error")
} else if !strings.Contains(err.Error(), keyringPasswordEnv) {
t.Fatalf("expected error mentioning %s, got: %v", keyringPasswordEnv, err)
}
}