feat: take over PR #18 integration
This commit is contained in:
commit
9d8d36a94e
@ -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
15
.lefthook.yml
Normal 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
|
||||
@ -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
|
||||
|
||||
2
Makefile
2
Makefile
@ -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
641
README.md
@ -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 you’ll 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 doesn’t 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` won’t 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 Zechner’s 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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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>",
|
||||
|
||||
104
internal/cmd/calendar_colors.go
Normal file
104
internal/cmd/calendar_colors.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
245
internal/cmd/calendar_colors_test.go
Normal file
245
internal/cmd/calendar_colors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
194
internal/cmd/calendar_conflicts.go
Normal file
194
internal/cmd/calendar_conflicts.go
Normal 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
|
||||
}
|
||||
397
internal/cmd/calendar_conflicts_test.go
Normal file
397
internal/cmd/calendar_conflicts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
98
internal/cmd/calendar_search.go
Normal file
98
internal/cmd/calendar_search.go
Normal 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
|
||||
}
|
||||
337
internal/cmd/calendar_search_test.go
Normal file
337
internal/cmd/calendar_search_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
88
internal/cmd/calendar_time.go
Normal file
88
internal/cmd/calendar_time.go
Normal 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
|
||||
}
|
||||
244
internal/cmd/calendar_time_test.go
Normal file
244
internal/cmd/calendar_time_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
62
internal/cmd/completion.go
Normal file
62
internal/cmd/completion.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
164
internal/cmd/gmail_autoforward.go
Normal file
164
internal/cmd/gmail_autoforward.go
Normal 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
|
||||
}
|
||||
68
internal/cmd/gmail_autoforward_test.go
Normal file
68
internal/cmd/gmail_autoforward_test.go
Normal 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
123
internal/cmd/gmail_batch.go
Normal 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
|
||||
}
|
||||
183
internal/cmd/gmail_concurrent_test.go
Normal file
183
internal/cmd/gmail_concurrent_test.go
Normal 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
|
||||
}
|
||||
189
internal/cmd/gmail_delegates.go
Normal file
189
internal/cmd/gmail_delegates.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
13
internal/cmd/gmail_delegates_test.go
Normal file
13
internal/cmd/gmail_delegates_test.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
388
internal/cmd/gmail_filters.go
Normal file
388
internal/cmd/gmail_filters.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
13
internal/cmd/gmail_filters_test.go
Normal file
13
internal/cmd/gmail_filters_test.go
Normal 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
|
||||
}
|
||||
190
internal/cmd/gmail_forwarding.go
Normal file
190
internal/cmd/gmail_forwarding.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
13
internal/cmd/gmail_forwarding_test.go
Normal file
13
internal/cmd/gmail_forwarding_test.go
Normal 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
|
||||
}
|
||||
@ -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()) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
336
internal/cmd/gmail_sendas.go
Normal file
336
internal/cmd/gmail_sendas.go
Normal 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
|
||||
}
|
||||
572
internal/cmd/gmail_sendas_test.go
Normal file
572
internal/cmd/gmail_sendas_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
205
internal/cmd/gmail_vacation.go
Normal file
205
internal/cmd/gmail_vacation.go
Normal 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
|
||||
}
|
||||
105
internal/cmd/gmail_vacation_test.go
Normal file
105
internal/cmd/gmail_vacation_test.go
Normal 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
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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
451
internal/cmd/sheets.go
Normal 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
|
||||
}
|
||||
74
internal/googleapi/circuitbreaker.go
Normal file
74
internal/googleapi/circuitbreaker.go
Normal 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"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
14
internal/googleapi/retry_constants.go
Normal file
14
internal/googleapi/retry_constants.go
Normal 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
|
||||
)
|
||||
@ -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) {
|
||||
|
||||
28
internal/googleapi/sheets.go
Normal file
28
internal/googleapi/sheets.go
Normal 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
|
||||
}
|
||||
220
internal/googleapi/transport.go
Normal file
220
internal/googleapi/transport.go
Normal 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()
|
||||
}
|
||||
306
internal/googleapi/transport_test.go
Normal file
306
internal/googleapi/transport_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
399
internal/googleauth/accounts_server.go
Normal file
399
internal/googleauth/accounts_server.go
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
848
internal/googleauth/templates.go
Normal file
848
internal/googleauth/templates.go
Normal 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>`
|
||||
1365
internal/googleauth/templates_new.go
Normal file
1365
internal/googleauth/templates_new.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user