From 84bb0f35f6db710a058b9a485273a2f14515d2da Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 18:54:29 +0200 Subject: [PATCH 1/7] feat(gmail): add Pub/Sub pull watch consumer --- docs/commands.generated.md | 1 + docs/commands/README.md | 1 + .../commands/gog-gmail-settings-watch-pull.md | 56 ++++ docs/commands/gog-gmail-settings-watch.md | 1 + docs/gmail-workflows.md | 3 +- go.mod | 6 + go.sum | 109 +++++++ internal/cmd/gmail_watch_cmds.go | 73 +++-- internal/cmd/gmail_watch_pull.go | 290 ++++++++++++++++++ internal/cmd/gmail_watch_pull_test.go | 286 +++++++++++++++++ internal/cmd/gmail_watch_server.go | 11 +- 11 files changed, 801 insertions(+), 36 deletions(-) create mode 100644 docs/commands/gog-gmail-settings-watch-pull.md create mode 100644 internal/cmd/gmail_watch_pull.go create mode 100644 internal/cmd/gmail_watch_pull_test.go diff --git a/docs/commands.generated.md b/docs/commands.generated.md index 92c02a9ed..f0c74ce3a 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 eef4065f4..1815c61e4 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -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 000000000..afac82477 --- /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 233bd7c8e..65f0f06ae 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 be8ec91ac..4aa8d0ac2 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/go.mod b/go.mod index 40e7d2ebc..b85b7358e 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 54eb67ccc..c9562a951 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 6b07d6ee3..1e1a903df 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,14 @@ 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 { - return err - } + return err } if c.SaveHook && hook != nil { if updateErr := store.Update(func(s *gmailWatchState) error { @@ -339,8 +322,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 +495,42 @@ 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 + } + if errors.Is(err, errNoHookConfigured) && allowNoHook { + return nil, 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 000000000..8d0b64681 --- /dev/null +++ b/internal/cmd/gmail_watch_pull.go @@ -0,0 +1,290 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "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 _, err := projectIDFromPubSubSubscription(subscription); err != nil { + return err + } + + 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") + } + + 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) { + result, err := s.handlePush(ctx, payload) + if err != nil { + return nil, err + } + if result == nil { + return nil, nil + } + 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 + } + return processed, nil +} + +type gmailWatchProcessedPayload struct { + Payload *gmailHookPayload + HookFailed bool +} diff --git a/internal/cmd/gmail_watch_pull_test.go b/internal/cmd/gmail_watch_pull_test.go new file mode 100644 index 000000000..85a26522b --- /dev/null +++ b/internal/cmd/gmail_watch_pull_test.go @@ -0,0 +1,286 @@ +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/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 nil, 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 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_AcksHookFailure(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) + } +} + +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 0eea82ab5..85ae03604 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) } From 7532eefe8e3b4bd36a2c57f90cf4454b6699a3f8 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Fri, 5 Jun 2026 21:19:16 +0200 Subject: [PATCH 2/7] fix(gmail): keep pull dry-run side-effect free --- internal/cmd/gmail_watch_cmds.go | 8 ++-- internal/cmd/gmail_watch_pull.go | 20 ++++++-- internal/cmd/gmail_watch_pull_test.go | 69 ++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/internal/cmd/gmail_watch_cmds.go b/internal/cmd/gmail_watch_cmds.go index 1e1a903df..5319bebae 100644 --- a/internal/cmd/gmail_watch_cmds.go +++ b/internal/cmd/gmail_watch_cmds.go @@ -287,7 +287,10 @@ func (c *GmailWatchServeCmd) Run(ctx context.Context, kctx *kong.Context, flags MaxBytes: c.MaxBytes, }, true) if err != nil { - return err + if !errors.Is(err, errNoHookConfigured) { + return err + } + hook = nil } if c.SaveHook && hook != nil { if updateErr := store.Update(func(s *gmailWatchState) error { @@ -525,9 +528,6 @@ func resolveWatchHookFromFlags(kctx *kong.Context, state gmailWatchState, values if err == nil { return hook, nil } - if errors.Is(err, errNoHookConfigured) && allowNoHook { - return nil, nil - } return nil, err } diff --git a/internal/cmd/gmail_watch_pull.go b/internal/cmd/gmail_watch_pull.go index 8d0b64681..0f4c7389d 100644 --- a/internal/cmd/gmail_watch_pull.go +++ b/internal/cmd/gmail_watch_pull.go @@ -40,8 +40,8 @@ func (c *GmailWatchPullCmd) Run(ctx context.Context, kctx *kong.Context, flags * if subscription == "" { return usage("--subscription is required") } - if _, err := projectIDFromPubSubSubscription(subscription); err != nil { - return err + if _, subscriptionErr := projectIDFromPubSubSubscription(subscription); subscriptionErr != nil { + return subscriptionErr } loc, err := resolveOutputLocation(c.Timezone, c.Local) @@ -59,6 +59,20 @@ func (c *GmailWatchPullCmd) Run(ctx context.Context, kctx *kong.Context, flags * 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 { @@ -271,7 +285,7 @@ func (s *gmailWatchServer) processGmailWatchPayload(ctx context.Context, payload return nil, err } if result == nil { - return nil, nil + return nil, errNoNewMessages } processed := &gmailWatchProcessedPayload{Payload: result} if s.cfg.HookURL == "" { diff --git a/internal/cmd/gmail_watch_pull_test.go b/internal/cmd/gmail_watch_pull_test.go index 85a26522b..04a181075 100644 --- a/internal/cmd/gmail_watch_pull_test.go +++ b/internal/cmd/gmail_watch_pull_test.go @@ -15,6 +15,7 @@ import ( "google.golang.org/api/gmail/v1" "google.golang.org/api/option" + "github.com/steipete/gogcli/internal/outfmt" "github.com/steipete/gogcli/internal/ui" ) @@ -96,7 +97,7 @@ func TestGmailWatchPullCmd_RequiresFullSubscriptionAndHook(t *testing.T) { t.Cleanup(func() { newGmailPubSubReceiver = origReceiver }) newGmailPubSubReceiver = func(context.Context, string, gmailPubSubReceiveSettings) (gmailPubSubReceiver, error) { t.Fatal("receiver should not be created") - return nil, nil + return &fakeGmailPubSubReceiver{}, nil } setWatchTestConfigHome(t) @@ -126,6 +127,72 @@ func TestGmailWatchPullCmd_RequiresFullSubscriptionAndHook(t *testing.T) { } } +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"}, From 127f586f3fabfa4fdbf1bd3f22d441671400532e Mon Sep 17 00:00:00 2001 From: joshp123 Date: Sat, 6 Jun 2026 02:14:19 +0200 Subject: [PATCH 3/7] fix(gmail): retry watch hook delivery failures --- docs/watch.md | 91 +++++++++++++++++--- internal/cmd/gmail_watch_pull.go | 32 +++++++ internal/cmd/gmail_watch_pull_test.go | 7 +- internal/cmd/gmail_watch_server_more_test.go | 5 +- 4 files changed, 119 insertions(+), 16 deletions(-) diff --git a/docs/watch.md b/docs/watch.md index b2163f659..468b334b2 100644 --- a/docs/watch.md +++ b/docs/watch.md @@ -1,22 +1,50 @@ --- -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. ## 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 +52,7 @@ gog gmail watch start \ --label INBOX ``` -5) Run handler: +4) Run handler: ``` gog gmail watch serve \ @@ -52,6 +80,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 +94,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 +180,29 @@ 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. +- Pub/Sub subscriber access on the configured subscription. + +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 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/internal/cmd/gmail_watch_pull.go b/internal/cmd/gmail_watch_pull.go index 0f4c7389d..4d1a75e39 100644 --- a/internal/cmd/gmail_watch_pull.go +++ b/internal/cmd/gmail_watch_pull.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "strings" "time" @@ -280,6 +281,10 @@ func (s *gmailWatchServer) handlePullMessage(ctx context.Context, msg *gmailPubS } 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 @@ -294,6 +299,10 @@ func (s *gmailWatchServer) processGmailWatchPayload(ctx context.Context, payload if err := s.sendHook(ctx, result); err != nil { s.warnf("watch: hook failed: %v", err) processed.HookFailed = true + if restoreErr := s.restoreWatchProgressForRetry(progressBefore); restoreErr != nil { + s.warnf("watch: failed to preserve retry state after hook failure: %v", restoreErr) + } + return processed, &gmailWatchHookDeliveryError{Err: err} } return processed, nil } @@ -302,3 +311,26 @@ 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) error { + if s.store == nil { + return nil + } + return s.store.Update(func(state *gmailWatchState) error { + 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 index 04a181075..83c8b9b57 100644 --- a/internal/cmd/gmail_watch_pull_test.go +++ b/internal/cmd/gmail_watch_pull_test.go @@ -213,7 +213,7 @@ func TestGmailWatchPullMessage_AcksInvalidAndWrongAccount(t *testing.T) { } } -func TestGmailWatchPullMessage_AcksHookFailure(t *testing.T) { +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) { @@ -222,12 +222,15 @@ func TestGmailWatchPullMessage_AcksHookFailure(t *testing.T) { msg, state := trackedPullMessage("m1", []byte(`{"emailAddress":"a@b.com","historyId":"200"}`)) server.handlePullMessage(context.Background(), msg) - if !state.acked || state.nacked { + 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 TestGmailWatchPullMessage_NacksGmailFailure(t *testing.T) { diff --git a/internal/cmd/gmail_watch_server_more_test.go b/internal/cmd/gmail_watch_server_more_test.go index 4db53842e..8e22e8a80 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) { From 686847002305334cfa0a96bea7ebc2313d09841d Mon Sep 17 00:00:00 2001 From: joshp123 Date: Sun, 7 Jun 2026 17:00:08 +0200 Subject: [PATCH 4/7] docs(gmail): clarify watch retry reliability --- CHANGELOG.md | 2 ++ docs/watch.md | 22 +++++++++++++--- internal/cmd/gmail_watch_pull_test.go | 37 +++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a970c61..cd746b40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Docs: add `--code` to `docs format` and plain-text `docs write` for the existing monospace grey code style. (#685) — thanks @sebsnyk. - Drive/Docs: add `--since` to `drive comments list` and `docs comments list` for server-side comment modified-time filtering. (#688) — thanks @sebsnyk. - Gmail: add `--thread-id` to `gmail drafts create` and `gmail drafts update` so drafts can reply within a thread using the latest message headers. (#673, #674) — thanks @chrischall. +- Gmail watch: add Pub/Sub pull delivery through `gog gmail watch pull` / `gog gmail settings watch pull`, so local agents can consume Gmail notifications without public HTTP ingress. (#700) ### Fixed @@ -36,6 +37,7 @@ - Docs: render GFM `~~strikethrough~~` spans in the local markdown writer used by `docs write --tab --markdown`. (#702) - Docs: batch table-cell writes for `docs write --tab --markdown` to avoid per-cell Docs API quota bursts on table-heavy documents. (#699) — thanks @sebsnyk. - Gmail: preserve existing `gmail drafts update` attachments when `--attach` is omitted, add `--clear-attachments` for intentional removal, and keep `--attach` as explicit replacement. (#680, #681) — thanks @chrischall. +- Gmail watch: make downstream hook failures retryable for push and pull by preserving the pre-hook watch cursor and returning a Pub/Sub delivery failure instead of acknowledging a notification that the downstream agent never received. (#700) ## 0.21.0 - 2026-06-01 diff --git a/docs/watch.md b/docs/watch.md index 468b334b2..a7ed9c732 100644 --- a/docs/watch.md +++ b/docs/watch.md @@ -16,7 +16,8 @@ Two delivery modes are supported: 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. + when you intentionally operate a reachable HTTPS endpoint. Push and pull share + the same downstream hook delivery policy. ## Quick start @@ -185,8 +186,14 @@ Fallback (dev only): 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. -- Pub/Sub subscriber access on the configured subscription. +- 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`. @@ -202,6 +209,15 @@ configured `--hook-url`. 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 diff --git a/internal/cmd/gmail_watch_pull_test.go b/internal/cmd/gmail_watch_pull_test.go index 83c8b9b57..cefd8f2fa 100644 --- a/internal/cmd/gmail_watch_pull_test.go +++ b/internal/cmd/gmail_watch_pull_test.go @@ -233,6 +233,43 @@ func TestGmailWatchPullMessage_NacksHookFailureAndPreservesProgress(t *testing.T } } +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() From fe834ae2516239816d48272efaa56bb7ebf51131 Mon Sep 17 00:00:00 2001 From: joshp123 Date: Sun, 7 Jun 2026 17:18:17 +0200 Subject: [PATCH 5/7] docs(gmail): leave watch release notes to release flow --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd746b40d..32a970c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,6 @@ - Docs: add `--code` to `docs format` and plain-text `docs write` for the existing monospace grey code style. (#685) — thanks @sebsnyk. - Drive/Docs: add `--since` to `drive comments list` and `docs comments list` for server-side comment modified-time filtering. (#688) — thanks @sebsnyk. - Gmail: add `--thread-id` to `gmail drafts create` and `gmail drafts update` so drafts can reply within a thread using the latest message headers. (#673, #674) — thanks @chrischall. -- Gmail watch: add Pub/Sub pull delivery through `gog gmail watch pull` / `gog gmail settings watch pull`, so local agents can consume Gmail notifications without public HTTP ingress. (#700) ### Fixed @@ -37,7 +36,6 @@ - Docs: render GFM `~~strikethrough~~` spans in the local markdown writer used by `docs write --tab --markdown`. (#702) - Docs: batch table-cell writes for `docs write --tab --markdown` to avoid per-cell Docs API quota bursts on table-heavy documents. (#699) — thanks @sebsnyk. - Gmail: preserve existing `gmail drafts update` attachments when `--attach` is omitted, add `--clear-attachments` for intentional removal, and keep `--attach` as explicit replacement. (#680, #681) — thanks @chrischall. -- Gmail watch: make downstream hook failures retryable for push and pull by preserving the pre-hook watch cursor and returning a Pub/Sub delivery failure instead of acknowledging a notification that the downstream agent never received. (#700) ## 0.21.0 - 2026-06-01 From e5ee7cc943ad61e51f93b555bed7510b6a4f7a0d Mon Sep 17 00:00:00 2001 From: joshp123 Date: Sun, 7 Jun 2026 17:29:44 +0200 Subject: [PATCH 6/7] chore(ci): retry checks after docker timeout From a2d1d8f0cb4d9304f6d41de0e53af218b25406fc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 9 Jun 2026 13:47:33 +0900 Subject: [PATCH 7/7] fix(gmail): guard watch retry rollback --- CHANGELOG.md | 4 +++ docs/commands/README.md | 2 +- internal/cmd/gmail_watch_pull.go | 10 +++++-- internal/cmd/gmail_watch_pull_test.go | 40 +++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a970c61..5e8c002ba 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/README.md b/docs/commands/README.md index 1815c61e4..eaa9f836f 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 diff --git a/internal/cmd/gmail_watch_pull.go b/internal/cmd/gmail_watch_pull.go index 4d1a75e39..f4d23c555 100644 --- a/internal/cmd/gmail_watch_pull.go +++ b/internal/cmd/gmail_watch_pull.go @@ -299,7 +299,7 @@ func (s *gmailWatchServer) processGmailWatchPayload(ctx context.Context, payload if err := s.sendHook(ctx, result); err != nil { s.warnf("watch: hook failed: %v", err) processed.HookFailed = true - if restoreErr := s.restoreWatchProgressForRetry(progressBefore); restoreErr != nil { + 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} @@ -324,11 +324,17 @@ func (e *gmailWatchHookDeliveryError) Unwrap() error { return e.Err } -func (s *gmailWatchServer) restoreWatchProgressForRetry(before gmailWatchState) error { +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 index cefd8f2fa..bd74497a8 100644 --- a/internal/cmd/gmail_watch_pull_test.go +++ b/internal/cmd/gmail_watch_pull_test.go @@ -233,6 +233,46 @@ func TestGmailWatchPullMessage_NacksHookFailureAndPreservesProgress(t *testing.T } } +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()