diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a970c6..5e8c002b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.23.1 - Unreleased +### Added + +- Gmail: add `gmail watch pull` for Pub/Sub pull subscription consumers with hook retry support. (#700) — thanks @joshp123. + ## 0.23.0 - 2026-06-09 ### Added diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 92c02a9e..f0c74ce3 100644 --- a/docs/commands.generated.md +++ b/docs/commands.generated.md @@ -398,6 +398,7 @@ Generated from `gog schema --json`. - [`gog gmail (mail,email) settings vacation get (info,show)`](commands/gog-gmail-settings-vacation-get.md) - Get current vacation responder settings - [`gog gmail (mail,email) settings vacation update (edit,set) [flags]`](commands/gog-gmail-settings-vacation-update.md) - Update vacation responder settings - [`gog gmail (mail,email) settings watch `](commands/gog-gmail-settings-watch.md) - Manage Gmail watch + - [`gog gmail (mail,email) settings watch pull [flags]`](commands/gog-gmail-settings-watch-pull.md) - Run Pub/Sub pull consumer - [`gog gmail (mail,email) settings watch renew (update) [flags]`](commands/gog-gmail-settings-watch-renew.md) - Renew Gmail watch using stored config - [`gog gmail (mail,email) settings watch serve [flags]`](commands/gog-gmail-settings-watch-serve.md) - Run Pub/Sub push handler - [`gog gmail (mail,email) settings watch start (begin) [flags]`](commands/gog-gmail-settings-watch-start.md) - Start Gmail watch for Pub/Sub diff --git a/docs/commands/README.md b/docs/commands/README.md index eef4065f..eaa9f836 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -2,7 +2,7 @@ Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments. -Generated pages: 588. +Generated pages: 589. ## Top-level Commands @@ -450,6 +450,7 @@ Generated pages: 588. - [gog gmail settings vacation get](gog-gmail-settings-vacation-get.md) - Get current vacation responder settings - [gog gmail settings vacation update](gog-gmail-settings-vacation-update.md) - Update vacation responder settings - [gog gmail settings watch](gog-gmail-settings-watch.md) - Manage Gmail watch + - [gog gmail settings watch pull](gog-gmail-settings-watch-pull.md) - Run Pub/Sub pull consumer - [gog gmail settings watch renew](gog-gmail-settings-watch-renew.md) - Renew Gmail watch using stored config - [gog gmail settings watch serve](gog-gmail-settings-watch-serve.md) - Run Pub/Sub push handler - [gog gmail settings watch start](gog-gmail-settings-watch-start.md) - Start Gmail watch for Pub/Sub diff --git a/docs/commands/gog-gmail-settings-watch-pull.md b/docs/commands/gog-gmail-settings-watch-pull.md new file mode 100644 index 00000000..afac8247 --- /dev/null +++ b/docs/commands/gog-gmail-settings-watch-pull.md @@ -0,0 +1,56 @@ +# `gog gmail settings watch pull` + +> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`. + +Run Pub/Sub pull consumer + +## Usage + +```bash +gog gmail (mail,email) settings watch pull [flags] +``` + +## Parent + +- [gog gmail settings watch](gog-gmail-settings-watch.md) + +## Flags + +| Flag | Type | Default | Help | +| --- | --- | --- | --- | +| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) | +| `-a`
`--account`
`--acct` | `string` | | Account email for API commands (gmail/calendar/chat/classroom/drive/drivelabels/docs/slides/contacts/tasks/people/sheets/forms/sites/appscript/analytics/searchconsole/youtube/photos) | +| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) | +| `--color` | `string` | auto | Color output: auto\|always\|never | +| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed | +| `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | +| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) | +| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children | +| `--exclude-labels` | `string` | SPAM,TRASH | List of Gmail label IDs to exclude from hook payload (e.g. SPAM,TRASH,Label_123). Set to empty string to disable. | +| `--fetch-delay` | `string` | 3s | Delay before fetching Gmail history (seconds or duration) | +| `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | +| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | +| `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | +| `--history-types` | `[]string` | | History types to include (repeatable, comma-separated: messageAdded,messageDeleted,labelAdded,labelRemoved). Default: messageAdded | +| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) | +| `--hook-token` | `string` | | Webhook bearer token | +| `--hook-url` | `string` | | Webhook URL to forward messages | +| `--include-body` | `bool` | | Include text/plain body in hook payload | +| `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--local` | `bool` | | Use local timezone (default behavior, useful to override --timezone) | +| `--max-bytes` | `int` | 20000 | Max bytes of body to include | +| `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | +| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | +| `--save-hook` | `bool` | | Persist hook settings to watch state | +| `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--subscription` | `string` | | Pub/Sub pull subscription (projects/.../subscriptions/...) | +| `-z`
`--timezone` | `string` | | Output timezone (IANA name, e.g. America/New_York, UTC). Default: local | +| `-v`
`--verbose` | `bool` | | Enable verbose logging | +| `--version` | `kong.VersionFlag` | | Print version and exit | +| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers | + +## See Also + +- [gog gmail settings watch](gog-gmail-settings-watch.md) +- [Command index](README.md) diff --git a/docs/commands/gog-gmail-settings-watch.md b/docs/commands/gog-gmail-settings-watch.md index 233bd7c8..65f0f06a 100644 --- a/docs/commands/gog-gmail-settings-watch.md +++ b/docs/commands/gog-gmail-settings-watch.md @@ -16,6 +16,7 @@ gog gmail (mail,email) settings watch ## Subcommands +- [gog gmail settings watch pull](gog-gmail-settings-watch-pull.md) - Run Pub/Sub pull consumer - [gog gmail settings watch renew](gog-gmail-settings-watch-renew.md) - Renew Gmail watch using stored config - [gog gmail settings watch serve](gog-gmail-settings-watch-serve.md) - Run Pub/Sub push handler - [gog gmail settings watch start](gog-gmail-settings-watch-start.md) - Start Gmail watch for Pub/Sub diff --git a/docs/gmail-workflows.md b/docs/gmail-workflows.md index be8ec91a..4aa8d0ac 100644 --- a/docs/gmail-workflows.md +++ b/docs/gmail-workflows.md @@ -66,7 +66,7 @@ For account-specific send blocking, use the no-send config commands: - [`gog config no-send list`](commands/gog-config-no-send-list.md) - [`gog config no-send remove`](commands/gog-config-no-send-remove.md) -## Watches and Push +## Watches and Pub/Sub Gmail watch/PubSub workflows are documented in [Gmail watch](watch.md). @@ -74,6 +74,7 @@ Key command pages: - [`gog gmail watch start`](commands/gog-gmail-settings-watch-start.md) - [`gog gmail watch serve`](commands/gog-gmail-settings-watch-serve.md) +- [`gog gmail watch pull`](commands/gog-gmail-settings-watch-pull.md) - [`gog gmail watch renew`](commands/gog-gmail-settings-watch-renew.md) - [`gog gmail history`](commands/gog-gmail-history.md) diff --git a/docs/watch.md b/docs/watch.md index b2163f65..a7ed9c73 100644 --- a/docs/watch.md +++ b/docs/watch.md @@ -1,22 +1,51 @@ --- -summary: "Gmail watch + Pub/Sub push in gog" +summary: "Gmail watch + Pub/Sub delivery in gog" read_when: - - Adding Gmail watch/push support + - Adding Gmail watch/Pub/Sub support - Wiring Gmail to downstream webhooks --- # Gmail watch -Goal: Gmail push → Pub/Sub → `gog` HTTP handler → downstream webhook. +Goal: Gmail publishes mailbox notifications to Pub/Sub, then `gog` turns those +notifications into downstream webhook payloads. + +Two delivery modes are supported: + +- Pull: `gog gmail watch pull` reads a Pub/Sub subscription from the local + machine. This is the preferred local-agent shape because Google does not need + an inbound HTTP route to the machine running `gog`. +- Push: `gog gmail watch serve` exposes an HTTP handler for Pub/Sub push. Use it + when you intentionally operate a reachable HTTPS endpoint. Push and pull share + the same downstream hook delivery policy. ## Quick start 1) Create a Pub/Sub topic (GCP project). -2) Create a push subscription targeting your `gog gmail watch serve` endpoint. -3) Configure push auth: +2) Create a pull subscription for the topic. +3) Start watch: + +``` +gog gmail watch start \ + --topic projects//topics/ \ + --label INBOX +``` + +4) Run pull consumer: + +``` +gog gmail watch pull \ + --subscription projects//subscriptions/ \ + --hook-url http://127.0.0.1:18789/hooks/agent +``` + +For push delivery instead: + +1) Create a push subscription targeting your `gog gmail watch serve` endpoint. +2) Configure push auth: - Preferred: OIDC JWT from a service account. - Fallback/dev: shared token header `x-gog-token` or `?token=`. -4) Start watch: +3) Start watch: ``` gog gmail watch start \ @@ -24,7 +53,7 @@ gog gmail watch start \ --label INBOX ``` -5) Run handler: +4) Run handler: ``` gog gmail watch serve \ @@ -52,6 +81,13 @@ gog gmail watch serve \ [--include-body] [--max-bytes ] [--exclude-labels ] \ [--history-types ...] [--save-hook] +gog gmail watch pull \ + --subscription projects//subscriptions/ \ + [--hook-url ] [--hook-token ] \ + [--fetch-delay ] \ + [--include-body] [--max-bytes ] [--exclude-labels ] \ + [--history-types ...] [--save-hook] + gog gmail history --since [--max ] [--page ] ``` @@ -59,12 +95,21 @@ Notes: - `watch start` stores `{historyId, expirationMs, topic, labels}` for account. - `watch renew` reuses stored topic/labels. - `watch stop` calls Gmail stop + clears state. -- `watch serve` uses stored hook if `--hook-url` not provided. -- `watch serve --exclude-labels` defaults to `SPAM,TRASH`; set to an empty string to disable. +- `watch serve` and `watch pull` use stored hook config if `--hook-url` is not + provided. +- `watch pull` needs Google credentials that can consume the Pub/Sub + subscription. +- `watch serve` needs an HTTP endpoint reachable by Pub/Sub. +- `watch serve` and `watch pull` default `--exclude-labels` to `SPAM,TRASH`; set to an empty string to disable. - Exclude label IDs are matched exactly (case-sensitive opaque IDs). -- `watch serve --fetch-delay` delays Gmail history fetch after each push (default `3s`) to avoid indexing races; accepts seconds (`5`) or Go durations (`5s`). -- `watch serve --history-types` accepts `messageAdded`, `messageDeleted`, `labelAdded`, `labelRemoved` (repeatable or comma-separated). Default: `messageAdded` (for backward compatibility). -- `watch serve --history-types` must include at least one non-empty type. +- `watch serve --fetch-delay` and `watch pull --fetch-delay` delay Gmail + history fetch after each notification (default `3s`) to avoid indexing races; + accepts seconds (`5`) or Go durations (`5s`). +- `watch serve --history-types` and `watch pull --history-types` accept + `messageAdded`, `messageDeleted`, `labelAdded`, `labelRemoved` (repeatable or + comma-separated). Default: `messageAdded` (for backward compatibility). +- `watch serve --history-types` and `watch pull --history-types` must include at + least one non-empty type. ## State @@ -136,8 +181,44 @@ Preferred: Fallback (dev only): - Shared token via `x-gog-token` header or `?token=`. +## Auth (pull) + +Pull delivery does not expose a public HTTP receiver. The local `gog` process +must have Google credentials for: + +- Gmail history reads for the watched account. These use the normal stored + `gog` Gmail OAuth account selected by `--account` / `--client`. +- Pub/Sub subscriber access on the configured subscription. These use the + Google Cloud client library credential chain, for example Application Default + Credentials or `GOOGLE_APPLICATION_CREDENTIALS`, not the stored Gmail OAuth + token. The credential must be able to consume the subscription; granting + `roles/pubsub.subscriber` on the subscription is the usual least-privilege + shape. + +The downstream hook token is still local to the hook call from `gog` to the +configured `--hook-url`. + ## Error handling - Stale historyId: fall back to `messages.list` (last N) + reset historyId. - Watch expired: `watch renew` error; rerun `watch start`. -- Hook failures: log and still advance historyId to avoid replay storms. +- Pull mode treats invalid Pub/Sub messages as poison messages: log and + acknowledge them rather than redelivering forever. Wrong-account + notifications are also terminal in both modes. +- Hook failures are retryable. `gog` records the hook failure status, preserves + the pre-hook watch cursor, and returns a delivery failure to Pub/Sub. This + lets Pub/Sub redeliver the notification after the downstream agent or gateway + comes back. +- This is an intentional reliability change for existing push deployments. + Older `watch serve` behavior acknowledged hook failures to avoid replay + storms, but that could silently lose Gmail wakeups when the downstream + OpenClaw gateway or agent was temporarily down. The supported behavior is now + delivery-before-cursor-advance for both push and pull: push returns non-2xx on + hook failure and pull nacks the message. +- Pub/Sub may retry the same notification until the hook succeeds or until the + subscription's retry/dead-letter policy takes over. Hook receivers should be + safe to call more than once for the same Gmail history notification. +- This retry policy is intended for normal Gmail notification volumes. If you + are processing very high mail rates, for example 1000 messages per minute, run + your own monitoring, alerting, backlog policy, and dead-letter/backpressure + setup instead of treating the default watcher as a complete queueing platform. diff --git a/go.mod b/go.mod index 40e7d2eb..b85b7358 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/steipete/gogcli go 1.26.2 require ( + cloud.google.com/go/pubsub/v2 v2.6.0 filippo.io/age v1.3.1 github.com/99designs/keyring v1.2.2 github.com/alecthomas/kong v1.15.0 @@ -21,9 +22,11 @@ require ( ) require ( + cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/iam v1.8.0 // indirect filippo.io/hpke v0.4.0 // indirect github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -50,6 +53,7 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect @@ -57,6 +61,8 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect diff --git a/go.sum b/go.sum index 54eb67cc..c9562a95 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,18 @@ c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.8.0 h1:e5QOdN1zQ3MTWYtXIf2buX+jxqvo2sKqBCOLrteLd1M= +cloud.google.com/go/iam v1.8.0/go.mod h1:IkWUaEeLK91WQqTKa/fi5xdHJbL49kv2j/vlAZQSJ+k= +cloud.google.com/go/pubsub/v2 v2.6.0 h1:8pjR0id+GTB+krKx5G6AGJoYrHog58w2Q89PCOrfM64= +cloud.google.com/go/pubsub/v2 v2.6.0/go.mod h1:4anqvV/w8Pcgu2tO0qr2XgsF3GXHowzryfQ5gOnVmWY= filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= @@ -14,6 +21,7 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMb github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI= @@ -22,16 +30,32 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -43,14 +67,35 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= @@ -78,8 +123,12 @@ github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ib github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -88,8 +137,14 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -98,6 +153,10 @@ github.com/yosuke-furukawa/json5 v0.1.1 h1:0F9mNwTvOuDNH243hoPqvf+dxa5QsKnZzU20u github.com/yosuke-furukawa/json5 v0.1.1/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.einride.tech/aip v0.83.0 h1:TI21IdeOnLTwZEJ3BxtImIZk6bsN2Q+sd0x99SLiQ+M= +go.einride.tech/aip v0.83.0/go.mod h1:E8+wdTApA70odnpFzJgsGogHozC2JCIhFJBKPr8bVig= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= @@ -114,39 +173,89 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/api v0.277.0 h1:HJfyJUiNeBBUMai7ez8u14wkp/gH/I4wpGbbO9o+cSk= google.golang.org/api v0.277.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/cmd/gmail_watch_cmds.go b/internal/cmd/gmail_watch_cmds.go index 6b07d6ee..5319beba 100644 --- a/internal/cmd/gmail_watch_cmds.go +++ b/internal/cmd/gmail_watch_cmds.go @@ -31,6 +31,7 @@ type GmailWatchCmd struct { Renew GmailWatchRenewCmd `cmd:"" name:"renew" aliases:"update" help:"Renew Gmail watch using stored config"` Stop GmailWatchStopCmd `cmd:"" name:"stop" aliases:"rm,delete" help:"Stop Gmail watch and clear stored state"` Serve GmailWatchServeCmd `cmd:"" name:"serve" help:"Run Pub/Sub push handler"` + Pull GmailWatchPullCmd `cmd:"" name:"pull" help:"Run Pub/Sub pull consumer"` } type GmailWatchStartCmd struct { @@ -279,32 +280,17 @@ func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags } state := store.Get() - hookURL := c.HookURL - hookToken := c.HookToken - includeBody := c.IncludeBody - maxBytes := c.MaxBytes - - if hookURL == "" && state.Hook != nil { - hookURL = state.Hook.URL - if !flagProvided(kctx, "hook-token") { - hookToken = state.Hook.Token - } - if !flagProvided(kctx, "include-body") { - includeBody = state.Hook.IncludeBody - } - if !flagProvided(kctx, "max-bytes") && state.Hook.MaxBytes > 0 { - maxBytes = state.Hook.MaxBytes - } - } - - maxChanged := flagProvided(kctx, "max-bytes") - hook, err := hookFromFlags(hookURL, hookToken, includeBody, maxBytes, maxChanged, true) + hook, err := resolveWatchHookFromFlags(kctx, state, watchHookFlagValues{ + URL: c.HookURL, + Token: c.HookToken, + IncludeBody: c.IncludeBody, + MaxBytes: c.MaxBytes, + }, true) if err != nil { - if errors.Is(err, errNoHookConfigured) { - hook = nil - } else { + if !errors.Is(err, errNoHookConfigured) { return err } + hook = nil } if c.SaveHook && hook != nil { if updateErr := store.Update(func(s *gmailWatchState) error { @@ -339,8 +325,8 @@ func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags FetchDelay: fetchDelay, HistoryTypes: historyTypes, AllowNoHook: hook == nil, - IncludeBody: includeBody, - MaxBodyBytes: maxBytes, + IncludeBody: c.IncludeBody, + MaxBodyBytes: c.MaxBytes, DateLocation: loc, ExcludeLabels: splitCommaList(c.ExcludeLabels), VerboseOutput: flags.Verbose, @@ -512,6 +498,39 @@ func hookFromFlags(url, token string, includeBody bool, maxBytes int, maxBytesCh }, nil } +type watchHookFlagValues struct { + URL string + Token string + IncludeBody bool + MaxBytes int +} + +func resolveWatchHookFromFlags(kctx *kong.Context, state gmailWatchState, values watchHookFlagValues, allowNoHook bool) (*gmailWatchHook, error) { + hookURL := values.URL + hookToken := values.Token + includeBody := values.IncludeBody + maxBytes := values.MaxBytes + + if hookURL == "" && state.Hook != nil { + hookURL = state.Hook.URL + if !flagProvided(kctx, "hook-token") { + hookToken = state.Hook.Token + } + if !flagProvided(kctx, "include-body") { + includeBody = state.Hook.IncludeBody + } + if !flagProvided(kctx, "max-bytes") && state.Hook.MaxBytes > 0 { + maxBytes = state.Hook.MaxBytes + } + } + + hook, err := hookFromFlags(hookURL, hookToken, includeBody, maxBytes, flagProvided(kctx, "max-bytes"), allowNoHook) + if err == nil { + return hook, nil + } + return nil, err +} + func isLoopbackHost(host string) bool { trimmed := strings.TrimSpace(host) if trimmed == "" { diff --git a/internal/cmd/gmail_watch_pull.go b/internal/cmd/gmail_watch_pull.go new file mode 100644 index 00000000..f4d23c55 --- /dev/null +++ b/internal/cmd/gmail_watch_pull.go @@ -0,0 +1,342 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "cloud.google.com/go/pubsub/v2" + "github.com/alecthomas/kong" + "google.golang.org/api/gmail/v1" + + "github.com/steipete/gogcli/internal/authclient" + "github.com/steipete/gogcli/internal/ui" +) + +type GmailWatchPullCmd struct { + Subscription string `name:"subscription" help:"Pub/Sub pull subscription (projects/.../subscriptions/...)"` + FetchDelay string `name:"fetch-delay" help:"Delay before fetching Gmail history (seconds or duration)" default:"3s"` + Timezone string `name:"timezone" short:"z" help:"Output timezone (IANA name, e.g. America/New_York, UTC). Default: local"` + Local bool `name:"local" help:"Use local timezone (default behavior, useful to override --timezone)"` + HookURL string `name:"hook-url" help:"Webhook URL to forward messages"` + HookToken string `name:"hook-token" help:"Webhook bearer token"` + IncludeBody bool `name:"include-body" help:"Include text/plain body in hook payload"` + MaxBytes int `name:"max-bytes" help:"Max bytes of body to include" default:"20000"` + HistoryTypes []string `name:"history-types" help:"History types to include (repeatable, comma-separated: messageAdded,messageDeleted,labelAdded,labelRemoved). Default: messageAdded"` + ExcludeLabels string `name:"exclude-labels" help:"List of Gmail label IDs to exclude from hook payload (e.g. SPAM,TRASH,Label_123). Set to empty string to disable." default:"SPAM,TRASH"` + SaveHook bool `name:"save-hook" help:"Persist hook settings to watch state"` +} + +func (c *GmailWatchPullCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + subscription := strings.TrimSpace(c.Subscription) + if subscription == "" { + return usage("--subscription is required") + } + if _, subscriptionErr := projectIDFromPubSubSubscription(subscription); subscriptionErr != nil { + return subscriptionErr + } + + loc, err := resolveOutputLocation(c.Timezone, c.Local) + if err != nil { + return err + } + historyTypes, err := parseHistoryTypes(c.HistoryTypes) + if err != nil { + return err + } + fetchDelay, err := parseDurationSeconds(c.FetchDelay) + if err != nil { + return err + } + if fetchDelay < 0 { + return usage("--fetch-delay must be >= 0") + } + if dryRunErr := dryRunExit(ctx, flags, "gmail.watch.pull", map[string]any{ + "account": account, + "subscription": subscription, + "fetch_delay_seconds": fetchDelay.Seconds(), + "history_types": historyTypes, + "exclude_labels": splitCommaList(c.ExcludeLabels), + "include_body": c.IncludeBody, + "max_bytes": c.MaxBytes, + "hook_url_set": strings.TrimSpace(c.HookURL) != "", + "hook_token_set": c.HookToken != "", + "save_hook": c.SaveHook, + }); dryRunErr != nil { + return dryRunErr + } + + store, err := loadGmailWatchStore(account) + if err != nil { + return err + } + state := store.Get() + hook, err := resolveWatchHookFromFlags(kctx, state, watchHookFlagValues{ + URL: c.HookURL, + Token: c.HookToken, + IncludeBody: c.IncludeBody, + MaxBytes: c.MaxBytes, + }, false) + if err != nil { + if errors.Is(err, errNoHookConfigured) { + return usage("--hook-url is required unless stored watch state has a hook") + } + return err + } + if c.SaveHook && hook != nil { + if updateErr := store.Update(func(s *gmailWatchState) error { + s.Hook = hook + s.UpdatedAtMs = time.Now().UnixMilli() + return nil + }); updateErr != nil { + return updateErr + } + } + + cfg := gmailWatchServeConfig{ + Account: account, + HookURL: hook.URL, + HookToken: hook.Token, + HookTimeout: defaultHookRequestTimeoutSec * time.Second, + HistoryMax: defaultHistoryMaxResults, + ResyncMax: defaultHistoryResyncMax, + FetchDelay: fetchDelay, + HistoryTypes: historyTypes, + IncludeBody: hook.IncludeBody, + MaxBodyBytes: hook.MaxBytes, + DateLocation: loc, + ExcludeLabels: splitCommaList(c.ExcludeLabels), + VerboseOutput: flags.Verbose, + } + if cfg.MaxBodyBytes <= 0 { + cfg.MaxBodyBytes = defaultHookMaxBytes + } + + selectedClient := strings.TrimSpace(flags.Client) + serviceFactory := func(ctx context.Context, account string) (*gmail.Service, error) { + if selectedClient != "" { + ctx = authclient.WithClient(ctx, selectedClient) + } + return newGmailService(ctx, account) + } + + receiver, err := newGmailPubSubReceiver(ctx, subscription, gmailPubSubReceiveSettings{ + MaxOutstandingMessages: 1, + }) + if err != nil { + return err + } + defer func() { + if closeErr := receiver.Close(); closeErr != nil { + u.Err().Linef("watch: failed to close Pub/Sub receiver: %v", closeErr) + } + }() + + processor := &gmailWatchServer{ + cfg: cfg, + store: store, + newService: serviceFactory, + hookClient: &http.Client{Timeout: cfg.HookTimeout}, + excludeLabelIDs: stringSet(cfg.ExcludeLabels), + logf: u.Err().Linef, + warnf: u.Err().Linef, + } + u.Err().Linef("watch: pulling from %s", subscription) + + err = receiver.Receive(ctx, processor.handlePullMessage) + if errors.Is(err, context.Canceled) { + return nil + } + return err +} + +type gmailPubSubReceiveSettings struct { + MaxOutstandingMessages int +} + +type gmailPubSubReceiver interface { + Receive(context.Context, func(context.Context, *gmailPubSubMessage)) error + Close() error +} + +type gmailPubSubMessage struct { + ID string + Data []byte + Attributes map[string]string + ack func() + nack func() +} + +func (m *gmailPubSubMessage) Ack() { + if m.ack != nil { + m.ack() + } +} + +func (m *gmailPubSubMessage) Nack() { + if m.nack != nil { + m.nack() + } +} + +var newGmailPubSubReceiver = newGoogleGmailPubSubReceiver + +type googleGmailPubSubReceiver struct { + client *pubsub.Client + subscriber *pubsub.Subscriber +} + +func newGoogleGmailPubSubReceiver(ctx context.Context, subscription string, settings gmailPubSubReceiveSettings) (gmailPubSubReceiver, error) { + projectID, err := projectIDFromPubSubSubscription(subscription) + if err != nil { + return nil, err + } + client, err := pubsub.NewClient(ctx, projectID) + if err != nil { + return nil, err + } + subscriber := client.Subscriber(subscription) + if settings.MaxOutstandingMessages > 0 { + subscriber.ReceiveSettings.MaxOutstandingMessages = settings.MaxOutstandingMessages + } + subscriber.ReceiveSettings.NumGoroutines = 1 + return &googleGmailPubSubReceiver{client: client, subscriber: subscriber}, nil +} + +func (r *googleGmailPubSubReceiver) Receive(ctx context.Context, f func(context.Context, *gmailPubSubMessage)) error { + return r.subscriber.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { + f(ctx, &gmailPubSubMessage{ + ID: msg.ID, + Data: msg.Data, + Attributes: msg.Attributes, + ack: msg.Ack, + nack: msg.Nack, + }) + }) +} + +func (r *googleGmailPubSubReceiver) Close() error { + return r.client.Close() +} + +func projectIDFromPubSubSubscription(subscription string) (string, error) { + parts := strings.Split(strings.TrimSpace(subscription), "/") + if len(parts) == 4 && + parts[0] == "projects" && + parts[1] != "" && + parts[2] == "subscriptions" && + parts[3] != "" { + return parts[1], nil + } + return "", usage("--subscription must be projects/{project}/subscriptions/{subscription}") +} + +func decodeGmailPullPayload(msg *gmailPubSubMessage) (gmailPushPayload, error) { + if msg == nil || len(msg.Data) == 0 { + return gmailPushPayload{}, errors.New("missing message data") + } + var payload gmailPushPayload + if err := json.Unmarshal(msg.Data, &payload); err != nil { + return gmailPushPayload{}, err + } + payload.MessageID = strings.TrimSpace(msg.ID) + return payload, nil +} + +func (s *gmailWatchServer) handlePullMessage(ctx context.Context, msg *gmailPubSubMessage) { + payload, err := decodeGmailPullPayload(msg) + if err != nil { + s.warnf("watch: invalid pull data: %v", err) + msg.Ack() + return + } + if payload.EmailAddress != "" && !strings.EqualFold(payload.EmailAddress, s.cfg.Account) { + s.warnf("watch: ignoring pull notification for %s", payload.EmailAddress) + msg.Ack() + return + } + + _, err = s.processGmailWatchPayload(ctx, payload) + if err == nil || errors.Is(err, errNoNewMessages) { + msg.Ack() + return + } + var rateErr *gmailWatchRateLimitError + if errors.As(err, &rateErr) { + s.warnf("watch: Gmail rate limit circuit open: %v", err) + msg.Nack() + return + } + s.warnf("watch: handle pull failed: %v", err) + msg.Nack() +} + +func (s *gmailWatchServer) processGmailWatchPayload(ctx context.Context, payload gmailPushPayload) (*gmailWatchProcessedPayload, error) { + var progressBefore gmailWatchState + if s.store != nil { + progressBefore = s.store.Get() + } + result, err := s.handlePush(ctx, payload) + if err != nil { + return nil, err + } + if result == nil { + return nil, errNoNewMessages + } + processed := &gmailWatchProcessedPayload{Payload: result} + if s.cfg.HookURL == "" { + return processed, nil + } + if err := s.sendHook(ctx, result); err != nil { + s.warnf("watch: hook failed: %v", err) + processed.HookFailed = true + if restoreErr := s.restoreWatchProgressForRetry(progressBefore, result.HistoryID, payload.MessageID); restoreErr != nil { + s.warnf("watch: failed to preserve retry state after hook failure: %v", restoreErr) + } + return processed, &gmailWatchHookDeliveryError{Err: err} + } + return processed, nil +} + +type gmailWatchProcessedPayload struct { + Payload *gmailHookPayload + HookFailed bool +} + +type gmailWatchHookDeliveryError struct { + Err error +} + +func (e *gmailWatchHookDeliveryError) Error() string { + return fmt.Sprintf("hook delivery failed: %v", e.Err) +} + +func (e *gmailWatchHookDeliveryError) Unwrap() error { + return e.Err +} + +func (s *gmailWatchServer) restoreWatchProgressForRetry(before gmailWatchState, historyID, pushMessageID string) error { + if s.store == nil { + return nil + } + return s.store.Update(func(state *gmailWatchState) error { + if state.HistoryID != historyID { + return nil + } + if pushMessageID != "" && state.LastPushMessageID != pushMessageID { + return nil + } + state.HistoryID = before.HistoryID + state.LastPushMessageID = before.LastPushMessageID + return nil + }) +} diff --git a/internal/cmd/gmail_watch_pull_test.go b/internal/cmd/gmail_watch_pull_test.go new file mode 100644 index 00000000..bd74497a --- /dev/null +++ b/internal/cmd/gmail_watch_pull_test.go @@ -0,0 +1,433 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "google.golang.org/api/gmail/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type fakeGmailPubSubReceiver struct { + received bool + closed bool + err error +} + +func (r *fakeGmailPubSubReceiver) Receive(context.Context, func(context.Context, *gmailPubSubMessage)) error { + r.received = true + return r.err +} + +func (r *fakeGmailPubSubReceiver) Close() error { + r.closed = true + return nil +} + +func TestGmailWatchPullCmd_UsesStoredHookAndReceiver(t *testing.T) { + origReceiver := newGmailPubSubReceiver + t.Cleanup(func() { newGmailPubSubReceiver = origReceiver }) + + setWatchTestConfigHome(t) + store, err := newGmailWatchStore("a@b.com") + if err != nil { + t.Fatalf("store: %v", err) + } + if updateErr := store.Update(func(s *gmailWatchState) error { + *s = gmailWatchState{ + Account: "a@b.com", + Topic: "projects/p/topics/t", + HistoryID: "100", + Hook: &gmailWatchHook{ + URL: "http://example.com/hook", + Token: "tok", + IncludeBody: true, + MaxBytes: 123, + }, + } + return nil + }); updateErr != nil { + t.Fatalf("seed: %v", updateErr) + } + + fakeReceiver := &fakeGmailPubSubReceiver{} + var gotSubscription string + var gotSettings gmailPubSubReceiveSettings + newGmailPubSubReceiver = func(_ context.Context, subscription string, settings gmailPubSubReceiveSettings) (gmailPubSubReceiver, error) { + gotSubscription = subscription + gotSettings = settings + return fakeReceiver, nil + } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + args := []string{ + "--subscription", "projects/p/subscriptions/s", + "--fetch-delay", "0", + } + if execErr := runKong(t, &GmailWatchPullCmd{}, args, ui.WithUI(context.Background(), u), &RootFlags{Account: "a@b.com"}); execErr != nil { + t.Fatalf("execute: %v", execErr) + } + if gotSubscription != "projects/p/subscriptions/s" { + t.Fatalf("subscription = %q", gotSubscription) + } + if gotSettings.MaxOutstandingMessages != 1 { + t.Fatalf("max outstanding = %d", gotSettings.MaxOutstandingMessages) + } + if !fakeReceiver.received || !fakeReceiver.closed { + t.Fatalf("expected receiver used and closed: %#v", fakeReceiver) + } +} + +func TestGmailWatchPullCmd_RequiresFullSubscriptionAndHook(t *testing.T) { + origReceiver := newGmailPubSubReceiver + t.Cleanup(func() { newGmailPubSubReceiver = origReceiver }) + newGmailPubSubReceiver = func(context.Context, string, gmailPubSubReceiveSettings) (gmailPubSubReceiver, error) { + t.Fatal("receiver should not be created") + return &fakeGmailPubSubReceiver{}, nil + } + + setWatchTestConfigHome(t) + store, err := newGmailWatchStore("a@b.com") + if err != nil { + t.Fatalf("store: %v", err) + } + if updateErr := store.Update(func(s *gmailWatchState) error { + *s = gmailWatchState{Account: "a@b.com", HistoryID: "100"} + return nil + }); updateErr != nil { + t.Fatalf("seed: %v", updateErr) + } + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + flags := &RootFlags{Account: "a@b.com"} + + if err := runKong(t, &GmailWatchPullCmd{}, []string{"--subscription", "plain-sub"}, ctx, flags); err == nil { + t.Fatalf("expected subscription validation error") + } + if err := runKong(t, &GmailWatchPullCmd{}, []string{"--subscription", "projects/p/subscriptions/s"}, ctx, flags); err == nil { + t.Fatalf("expected missing hook error") + } +} + +func TestGmailWatchPullCmd_DryRunDoesNotCreateReceiverOrState(t *testing.T) { + origReceiver := newGmailPubSubReceiver + t.Cleanup(func() { newGmailPubSubReceiver = origReceiver }) + newGmailPubSubReceiver = func(context.Context, string, gmailPubSubReceiveSettings) (gmailPubSubReceiver, error) { + t.Fatal("receiver should not be created during dry-run") + return &fakeGmailPubSubReceiver{}, nil + } + + setWatchTestConfigHome(t) + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + args := []string{ + "--subscription", "projects/p/subscriptions/s", + "--fetch-delay", "0", + "--history-types", "messageAdded", + "--hook-url", "http://127.0.0.1:18789/hooks/gmail", + "--hook-token", "secret", + "--save-hook", + } + + out := captureStdout(t, func() { + err = runKong(t, &GmailWatchPullCmd{}, args, ctx, &RootFlags{ + Account: "a@b.com", + DryRun: true, + }) + }) + if ExitCode(err) != 0 { + t.Fatalf("expected dry-run exit 0, got %v", err) + } + + var got struct { + DryRun bool `json:"dry_run"` + Request struct { + Account string `json:"account"` + Subscription string `json:"subscription"` + HookURLSet bool `json:"hook_url_set"` + HookTokenSet bool `json:"hook_token_set"` + SaveHook bool `json:"save_hook"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("parse dry-run JSON: %v\n%s", err, out) + } + if !got.DryRun || + got.Request.Account != "a@b.com" || + got.Request.Subscription != "projects/p/subscriptions/s" || + !got.Request.HookURLSet || + !got.Request.HookTokenSet || + !got.Request.SaveHook { + t.Fatalf("unexpected dry-run payload: %#v", got) + } + if strings.Contains(out, "secret") { + t.Fatalf("dry-run output leaked hook token: %s", out) + } + + watchDir := os.Getenv("XDG_CONFIG_HOME") + if _, err := os.Stat(watchDir); err == nil { + t.Fatalf("dry-run created config directory %s", watchDir) + } else if !os.IsNotExist(err) { + t.Fatalf("stat config directory: %v", err) + } +} + +func TestGmailWatchPullMessage_AcksInvalidAndWrongAccount(t *testing.T) { + server := &gmailWatchServer{ + cfg: gmailWatchServeConfig{Account: "a@b.com"}, + logf: func(string, ...any) {}, + warnf: func(string, ...any) {}, + } + + invalid, invalidState := trackedPullMessage("m1", []byte("{")) + server.handlePullMessage(context.Background(), invalid) + if !invalidState.acked || invalidState.nacked { + t.Fatalf("invalid payload ack=%v nack=%v", invalidState.acked, invalidState.nacked) + } + + wrong, wrongState := trackedPullMessage("m2", []byte(`{"emailAddress":"other@example.com","historyId":"200"}`)) + server.handlePullMessage(context.Background(), wrong) + if !wrongState.acked || wrongState.nacked { + t.Fatalf("wrong account ack=%v nack=%v", wrongState.acked, wrongState.nacked) + } +} + +func TestGmailWatchPullMessage_NacksHookFailureAndPreservesProgress(t *testing.T) { + server, hook, cleanup := newPullProcessorTestServer(t, http.StatusOK) + defer cleanup() + hook.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + + msg, state := trackedPullMessage("m1", []byte(`{"emailAddress":"a@b.com","historyId":"200"}`)) + server.handlePullMessage(context.Background(), msg) + if state.acked || !state.nacked { + t.Fatalf("hook failure ack=%v nack=%v", state.acked, state.nacked) + } + if status := server.store.Get().LastDeliveryStatus; status != gmailWatchStatusHTTPError { + t.Fatalf("delivery status = %q", status) + } + if historyID := server.store.Get().HistoryID; historyID != "100" { + t.Fatalf("history id = %q", historyID) + } +} + +func TestGmailWatchRestoreProgressForRetrySkipsNewerProgress(t *testing.T) { + setWatchTestConfigHome(t) + + store, err := newGmailWatchStore("a@b.com") + if err != nil { + t.Fatalf("store: %v", err) + } + if updateErr := store.Update(func(s *gmailWatchState) error { + *s = gmailWatchState{ + Account: "a@b.com", + HistoryID: "100", + LastPushMessageID: "msg-before", + } + return nil + }); updateErr != nil { + t.Fatalf("seed: %v", updateErr) + } + before := store.Get() + if updateErr := store.Update(func(s *gmailWatchState) error { + s.HistoryID = "300" + s.LastPushMessageID = "msg-newer" + return nil + }); updateErr != nil { + t.Fatalf("advance: %v", updateErr) + } + + server := &gmailWatchServer{store: store} + if err := server.restoreWatchProgressForRetry(before, "200", "msg-failed"); err != nil { + t.Fatalf("restore: %v", err) + } + + state := store.Get() + if state.HistoryID != "300" { + t.Fatalf("history id = %q", state.HistoryID) + } + if state.LastPushMessageID != "msg-newer" { + t.Fatalf("last push message id = %q", state.LastPushMessageID) + } +} + +func TestGmailWatchPullMessage_RetriesHookFailureThenAcksSuccess(t *testing.T) { + server, hook, cleanup := newPullProcessorTestServer(t, http.StatusOK) + defer cleanup() + + hookStatus := http.StatusInternalServerError + hookRequests := 0 + hook.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hookRequests++ + w.WriteHeader(hookStatus) + }) + + first, firstState := trackedPullMessage("m1", []byte(`{"emailAddress":"a@b.com","historyId":"200"}`)) + server.handlePullMessage(context.Background(), first) + if firstState.acked || !firstState.nacked { + t.Fatalf("first delivery ack=%v nack=%v", firstState.acked, firstState.nacked) + } + if historyID := server.store.Get().HistoryID; historyID != "100" { + t.Fatalf("history id after failure = %q", historyID) + } + + hookStatus = http.StatusNoContent + second, secondState := trackedPullMessage("m1", []byte(`{"emailAddress":"a@b.com","historyId":"200"}`)) + server.handlePullMessage(context.Background(), second) + if !secondState.acked || secondState.nacked { + t.Fatalf("second delivery ack=%v nack=%v", secondState.acked, secondState.nacked) + } + if status := server.store.Get().LastDeliveryStatus; status != "ok" { + t.Fatalf("delivery status after retry = %q", status) + } + if historyID := server.store.Get().HistoryID; historyID != "200" { + t.Fatalf("history id after retry = %q", historyID) + } + if hookRequests != 2 { + t.Fatalf("hook requests = %d", hookRequests) + } +} + +func TestGmailWatchPullMessage_NacksGmailFailure(t *testing.T) { + server, _, cleanup := newPullProcessorTestServer(t, http.StatusInternalServerError) + defer cleanup() + + msg, state := trackedPullMessage("m1", []byte(`{"emailAddress":"a@b.com","historyId":"200"}`)) + server.handlePullMessage(context.Background(), msg) + if state.acked || !state.nacked { + t.Fatalf("gmail failure ack=%v nack=%v", state.acked, state.nacked) + } +} + +type trackedPullMessageState struct { + acked bool + nacked bool +} + +func trackedPullMessage(id string, data []byte) (*gmailPubSubMessage, *trackedPullMessageState) { + state := &trackedPullMessageState{} + msg := &gmailPubSubMessage{ + ID: id, + Data: data, + ack: func() { + state.acked = true + }, + nack: func() { + state.nacked = true + }, + } + return msg, state +} + +func newPullProcessorTestServer(t *testing.T, historyStatus int) (*gmailWatchServer, *httptest.Server, func()) { + t.Helper() + setWatchTestConfigHome(t) + + store, err := newGmailWatchStore("a@b.com") + if err != nil { + t.Fatalf("store: %v", err) + } + if updateErr := store.Update(func(s *gmailWatchState) error { + *s = gmailWatchState{ + Account: "a@b.com", + HistoryID: "100", + } + return nil + }); updateErr != nil { + t.Fatalf("seed: %v", updateErr) + } + + gmailServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/history"): + if historyStatus != http.StatusOK { + w.WriteHeader(historyStatus) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "historyId": "200", + "history": []map[string]any{ + {"messagesAdded": []map[string]any{{"message": map[string]any{"id": "m1"}}}}, + }, + }) + return + case strings.Contains(r.URL.Path, "/gmail/v1/users/me/messages/m1"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "m1", + "threadId": "t1", + "snippet": "hi", + "labelIds": []string{"INBOX"}, + "payload": map[string]any{ + "headers": []map[string]any{{"name": "Subject", "value": "S"}}, + "mimeType": "text/plain", + "body": map[string]any{ + "data": base64.RawURLEncoding.EncodeToString([]byte("body")), + }, + }, + }) + return + default: + http.NotFound(w, r) + } + })) + + gsvc, err := gmail.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(gmailServer.Client()), + option.WithEndpoint(gmailServer.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + + hookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + processor := &gmailWatchServer{ + cfg: gmailWatchServeConfig{ + Account: "a@b.com", + HookURL: hookServer.URL, + HookTimeout: defaultHookRequestTimeoutSec * time.Second, + HistoryMax: 100, + ResyncMax: 10, + FetchDelay: 0, + MaxBodyBytes: defaultHookMaxBytes, + }, + store: store, + newService: func(context.Context, string) (*gmail.Service, error) { return gsvc, nil }, + hookClient: hookServer.Client(), + excludeLabelIDs: map[string]struct{}{}, + logf: func(string, ...any) {}, + warnf: func(string, ...any) {}, + } + cleanup := func() { + gmailServer.Close() + hookServer.Close() + _ = os.Remove(store.path) + } + return processor, hookServer, cleanup +} diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go index 0eea82ab..85ae0360 100644 --- a/internal/cmd/gmail_watch_server.go +++ b/internal/cmd/gmail_watch_server.go @@ -88,7 +88,7 @@ func (s *gmailWatchServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - result, err := s.handlePush(r.Context(), payload) + processed, err := s.processGmailWatchPayload(r.Context(), payload) if err != nil { if errors.Is(err, errNoNewMessages) { w.WriteHeader(http.StatusAccepted) @@ -107,25 +107,20 @@ func (s *gmailWatchServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - if result == nil { + if processed == nil || processed.Payload == nil { w.WriteHeader(http.StatusAccepted) return } if s.cfg.HookURL == "" { if s.cfg.AllowNoHook { - _ = json.NewEncoder(w).Encode(result) + _ = json.NewEncoder(w).Encode(processed.Payload) return } w.WriteHeader(http.StatusAccepted) return } - if err := s.sendHook(r.Context(), result); err != nil { - s.warnf("watch: hook failed: %v", err) - w.WriteHeader(http.StatusOK) - return - } w.WriteHeader(http.StatusOK) } diff --git a/internal/cmd/gmail_watch_server_more_test.go b/internal/cmd/gmail_watch_server_more_test.go index 4db53842..8e22e8a8 100644 --- a/internal/cmd/gmail_watch_server_more_test.go +++ b/internal/cmd/gmail_watch_server_more_test.go @@ -824,12 +824,15 @@ func TestGmailWatchServer_ServeHTTP_HookError(t *testing.T) { rr := httptest.NewRecorder() req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/hook", bytes.NewReader(body)) server.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { + if rr.Code != http.StatusInternalServerError { t.Fatalf("status: %d", rr.Code) } if store.Get().LastDeliveryStatus != "http_error" { t.Fatalf("unexpected state: %#v", store.Get()) } + if store.Get().HistoryID != "100" { + t.Fatalf("history id: %q", store.Get().HistoryID) + } } func TestIsStaleHistoryError(t *testing.T) {