@zackbart/connecta 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +393 -0
- package/README.md +62 -105
- package/SECURITY.md +3 -2
- package/bin/connecta.mjs +41 -16
- package/bin/version.mjs +1 -1
- package/dist/access-tokens.d.ts +2 -1
- package/dist/access-tokens.js +66 -30
- package/dist/activity.d.ts +1 -1
- package/dist/activity.js +2 -0
- package/dist/agent-config.d.ts +23 -0
- package/dist/agent-config.js +59 -0
- package/dist/agent-description.js +16 -0
- package/dist/agent-identity.js +4 -4
- package/dist/agent-observation.js +39 -0
- package/dist/auth/clerk-sdk/client.d.ts +2 -0
- package/dist/auth/clerk-sdk/generated.js +554 -563
- package/dist/auth/clerk-transport.js +70 -3
- package/dist/auth/clerk.d.ts +13 -0
- package/dist/auth/clerk.js +653 -246
- package/dist/auth/cloudflare-access.js +46 -42
- package/dist/auth/downstream-client-metadata.js +2 -2
- package/dist/auth/downstream-oauth.js +2051 -1160
- package/dist/auth/downstream-revocation.js +88 -88
- package/dist/auth/native-call.js +20 -0
- package/dist/auth/native.js +184 -0
- package/dist/auth/oauth-start-reset.js +11 -5
- package/dist/auth/oauth-token-response.js +121 -102
- package/dist/auth/oauth-v2-migration.js +97 -78
- package/dist/auth/renewable-grant.js +10 -0
- package/dist/auth/static-oauth.js +663 -271
- package/dist/auth/vercel-agent.js +266 -0
- package/dist/auth-admission.js +204 -0
- package/dist/auth-elicitation.js +350 -241
- package/dist/authorization-handoff.js +164 -90
- package/dist/call-admission-context.js +3 -10
- package/dist/call-admission.js +2 -0
- package/dist/call-classification.js +9 -0
- package/dist/call-recovery.js +32 -13
- package/dist/canonical-json.js +8 -0
- package/dist/catalog-cache.js +369 -213
- package/dist/catalog-service.js +659 -530
- package/dist/catalog.js +5 -1
- package/dist/concurrency.js +0 -25
- package/dist/config-defaults.d.ts +1 -0
- package/dist/config-defaults.js +1 -0
- package/dist/config-schema.d.ts +3 -1
- package/dist/config-schema.js +13 -3
- package/dist/config.d.ts +40 -6
- package/dist/config.js +40 -26
- package/dist/connector-caller.d.ts +4 -0
- package/dist/connector-caller.js +17 -4
- package/dist/connectors/api-connector.d.ts +15 -7
- package/dist/connectors/api-connector.js +147 -57
- package/dist/connectors/guarded-fetch.d.ts +51 -17
- package/dist/connectors/guarded-fetch.js +215 -121
- package/dist/connectors/negotiation-cache.js +20 -10
- package/dist/connectors/option-shapes.js +1 -1
- package/dist/connectors/remote-mcp.d.ts +12 -30
- package/dist/connectors/remote-mcp.js +960 -564
- package/dist/d1.js +10 -243
- package/dist/derived-context.js +5 -0
- package/dist/describe-config.d.ts +5 -1
- package/dist/describe-config.js +3 -0
- package/dist/downstream-input.js +347 -260
- package/dist/errors.d.ts +87 -6
- package/dist/errors.js +114 -4
- package/dist/execute.js +87 -50
- package/dist/executor-admission.js +3 -0
- package/dist/executors/quickjs.js +163 -112
- package/dist/guarded-transport.js +50 -0
- package/dist/guest-runtime.js +1 -1
- package/dist/http/client.js +439 -0
- package/dist/http/errors.js +558 -0
- package/dist/http/fields.js +49 -0
- package/dist/http/hatch-paging.js +318 -0
- package/dist/http/hatch.js +1279 -0
- package/dist/http/json-schema.js +267 -0
- package/dist/http/node-transport.js +241 -0
- package/dist/http/oauth.js +162 -0
- package/dist/http/paging.js +228 -0
- package/dist/http/provider.js +221 -0
- package/dist/http/safety.js +82 -0
- package/dist/http/services.js +79 -0
- package/dist/http/tools.js +383 -0
- package/dist/http/vendor-index.js +138 -0
- package/dist/http/vendor.js +448 -0
- package/dist/http/write-request.js +66 -0
- package/dist/http-policy.d.ts +11 -0
- package/dist/http-policy.js +45 -0
- package/dist/inbound-credential.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +4 -0
- package/dist/invocation-auth.js +30 -19
- package/dist/invocation.js +221 -87
- package/dist/mcp-result.js +0 -4
- package/dist/membership.d.ts +4 -0
- package/dist/meta-output.js +1 -1
- package/dist/meta-tools.js +534 -408
- package/dist/native-recovery.js +52 -0
- package/dist/native-tools.js +382 -0
- package/dist/node.d.ts +4 -3
- package/dist/node.js +45 -8
- package/dist/oauth-handoff.js +177 -104
- package/dist/oauth-partition.js +6 -20
- package/dist/oauth-sealing.js +6 -7
- package/dist/operation-vocabulary.js +101 -0
- package/dist/operator-record.d.ts +2 -2
- package/dist/operator-record.js +17 -4
- package/dist/operator-ui/agents.d.ts +5 -4
- package/dist/operator-ui/agents.js +17 -7
- package/dist/operator-ui/config-summary.js +8 -4
- package/dist/operator-ui/connection-map.js +9 -6
- package/dist/operator-ui/contract.d.ts +7 -3
- package/dist/operator-ui/endpoints.js +5 -14
- package/dist/operator-ui/fix-prompts.d.ts +1 -1
- package/dist/operator-ui/fix-prompts.js +0 -8
- package/dist/operator-ui/generated.js +3 -3
- package/dist/operator-ui/health.js +52 -0
- package/dist/operator-ui/model.d.ts +2 -3
- package/dist/operator-ui/overview.js +11 -19
- package/dist/operator-ui/setup-commands.js +0 -4
- package/dist/operator-ui/view.d.ts +5 -29
- package/dist/operator-ui/view.js +3 -59
- package/dist/program-writes.js +47 -26
- package/dist/provider-call-facts.d.ts +115 -0
- package/dist/provider-call-facts.js +182 -0
- package/dist/provider-call.d.ts +112 -0
- package/dist/provider-call.js +271 -0
- package/dist/provider.d.ts +71 -32
- package/dist/provider.js +134 -40
- package/dist/providers/_shared/google/hatch.js +91 -0
- package/dist/providers/_shared/google/kit.js +537 -0
- package/dist/providers/_shared/google/result-size.js +15 -2
- package/dist/providers/_shared/google/service-account.d.ts +2 -1
- package/dist/providers/_shared/google/service-account.js +8 -4
- package/dist/providers/_shared/google/workspace.d.ts +8 -209
- package/dist/providers/_shared/google/workspace.js +7 -426
- package/dist/providers/_shared/rest/compact-index.js +86 -0
- package/dist/providers/_shared/rest/dispatch.js +3 -23
- package/dist/providers/_shared/rest/kit.js +546 -0
- package/dist/providers/_shared/rest/operation-index.js +230 -14
- package/dist/providers/_shared/rest/response-media.js +11 -0
- package/dist/providers/_shared/rest/tools.js +101 -78
- package/dist/providers/_shared/rest/value-safety.js +353 -195
- package/dist/providers/_shared/sensitive-fields.js +27 -0
- package/dist/providers/breeze/api.js +584 -0
- package/dist/providers/breeze/index.d.ts +3 -3
- package/dist/providers/breeze/index.js +67 -1420
- package/dist/providers/breeze/openapi.generated.js +60 -0
- package/dist/providers/breeze/skill.generated.js +2 -6
- package/dist/providers/breeze/tools.js +696 -0
- package/dist/providers/ccb/api.js +496 -0
- package/dist/providers/ccb/index.d.ts +17 -37
- package/dist/providers/ccb/index.js +77 -1702
- package/dist/providers/ccb/lib/constants.d.ts +34 -0
- package/dist/providers/ccb/lib/constants.js +74 -0
- package/dist/providers/ccb/lib/guide.js +9 -0
- package/dist/providers/ccb/lib/verdicts.js +387 -0
- package/dist/providers/ccb/openapi.generated.js +397 -0
- package/dist/providers/ccb/safety.js +295 -0
- package/dist/providers/ccb/skill.generated.js +2 -6
- package/dist/providers/ccb/tools.js +853 -0
- package/dist/providers/cloudflare/api.js +1066 -0
- package/dist/providers/cloudflare/index.d.ts +7 -22
- package/dist/providers/cloudflare/index.js +80 -194
- package/dist/providers/cloudflare/lib/config.d.ts +16 -0
- package/dist/providers/cloudflare/lib/config.js +2 -0
- package/dist/providers/cloudflare/lib/discovery.js +29 -0
- package/dist/providers/cloudflare/{graphql.js → lib/graphql.js} +1 -1
- package/dist/providers/cloudflare/lib/hatch.js +103 -0
- package/dist/providers/cloudflare/lib/path-bounds.js +28 -0
- package/dist/providers/cloudflare/lib/response-media.js +75 -0
- package/dist/providers/cloudflare/openapi.generated.js +3299 -3208
- package/dist/providers/cloudflare/{value-safety.js → safety.js} +538 -18
- package/dist/providers/cloudflare/skill.generated.js +2 -10
- package/dist/providers/cloudflare/tools.js +360 -0
- package/dist/providers/docs/api.js +51 -0
- package/dist/providers/docs/index.d.ts +1 -12
- package/dist/providers/docs/index.js +53 -1165
- package/dist/providers/docs/lib/constants.d.ts +18 -0
- package/dist/providers/docs/lib/constants.js +18 -0
- package/dist/providers/docs/lib/discovery.js +26 -0
- package/dist/providers/docs/lib/requests.js +48 -0
- package/dist/providers/docs/lib/schemas.js +168 -0
- package/dist/providers/docs/openapi.generated.js +16 -0
- package/dist/providers/docs/safety.js +54 -0
- package/dist/providers/docs/skill.generated.js +2 -6
- package/dist/providers/docs/tools.js +802 -0
- package/dist/providers/drive/api.js +177 -0
- package/dist/providers/drive/index.d.ts +1 -12
- package/dist/providers/drive/index.js +38 -1448
- package/dist/providers/drive/lib/constants.d.ts +6 -0
- package/dist/providers/drive/lib/constants.js +6 -0
- package/dist/providers/drive/lib/contracts.js +532 -0
- package/dist/providers/drive/lib/hatch.js +197 -0
- package/dist/providers/drive/openapi.generated.js +77 -0
- package/dist/providers/drive/safety.js +90 -0
- package/dist/providers/drive/skill.generated.js +2 -6
- package/dist/providers/drive/tools.js +1290 -0
- package/dist/providers/forms/api.js +71 -0
- package/dist/providers/forms/index.d.ts +1 -12
- package/dist/providers/forms/index.js +49 -1145
- package/dist/providers/forms/lib/constants.d.ts +14 -0
- package/dist/providers/forms/lib/constants.js +19 -0
- package/dist/providers/forms/lib/hatch.js +72 -0
- package/dist/providers/forms/openapi.generated.js +23 -0
- package/dist/providers/forms/safety.js +34 -0
- package/dist/providers/forms/skill.generated.js +2 -8
- package/dist/providers/forms/tools.js +1024 -0
- package/dist/providers/github/api.js +150 -0
- package/dist/providers/github/index.d.ts +4 -5
- package/dist/providers/github/index.js +30 -87
- package/dist/providers/github/{auth.d.ts → lib/auth.d.ts} +5 -3
- package/dist/providers/github/{auth.js → lib/auth.js} +40 -43
- package/dist/providers/github/lib/discovery.js +132 -0
- package/dist/providers/github/lib/operations.js +58 -0
- package/dist/providers/github/{scope.d.ts → lib/scope.d.ts} +2 -0
- package/dist/providers/github/{scope.js → lib/scope.js} +25 -1
- package/dist/providers/github/openapi.generated.js +1216 -0
- package/dist/providers/github/safety.js +216 -0
- package/dist/providers/github/skill.generated.js +2 -5
- package/dist/providers/github/tools.js +506 -0
- package/dist/providers/gmail/api.js +114 -0
- package/dist/providers/gmail/index.d.ts +1 -12
- package/dist/providers/gmail/index.js +73 -1621
- package/dist/providers/gmail/lib/constants.d.ts +13 -0
- package/dist/providers/gmail/lib/constants.js +16 -0
- package/dist/providers/gmail/lib/index.js +173 -0
- package/dist/providers/gmail/lib/schemas.js +176 -0
- package/dist/providers/gmail/lib/threading.js +120 -0
- package/dist/providers/gmail/openapi.generated.js +92 -0
- package/dist/providers/gmail/safety.js +21 -0
- package/dist/providers/gmail/skill.generated.js +2 -6
- package/dist/providers/gmail/tools.js +1435 -0
- package/dist/providers/overflow/api.js +1008 -0
- package/dist/providers/overflow/index.d.ts +3 -17
- package/dist/providers/overflow/index.js +60 -1488
- package/dist/providers/overflow/skill.generated.js +2 -5
- package/dist/providers/overflow/tools.js +873 -0
- package/dist/providers/planning-center/api.js +467 -0
- package/dist/providers/planning-center/index.d.ts +6 -35
- package/dist/providers/planning-center/index.js +69 -2445
- package/dist/providers/planning-center/lib/constants.d.ts +58 -0
- package/dist/providers/planning-center/lib/constants.js +83 -0
- package/dist/providers/planning-center/lib/discovery.js +40 -0
- package/dist/providers/planning-center/lib/guide.js +39 -0
- package/dist/providers/planning-center/lib/json-api.js +83 -0
- package/dist/providers/planning-center/lib/verdicts.js +1758 -0
- package/dist/providers/planning-center/openapi.generated.js +1764 -0
- package/dist/providers/planning-center/safety.js +188 -0
- package/dist/providers/planning-center/skill.generated.js +2 -27
- package/dist/providers/planning-center/tools.js +1010 -0
- package/dist/providers/sheets/api.js +133 -0
- package/dist/providers/sheets/index.d.ts +4 -16
- package/dist/providers/sheets/index.js +61 -1172
- package/dist/providers/sheets/lib/constants.d.ts +14 -0
- package/dist/providers/sheets/lib/constants.js +14 -0
- package/dist/providers/sheets/lib/hatch.js +163 -0
- package/dist/providers/sheets/lib/requests.js +105 -0
- package/dist/providers/sheets/lib/schemas.js +138 -0
- package/dist/providers/sheets/openapi.generated.js +30 -0
- package/dist/providers/sheets/safety.js +27 -0
- package/dist/providers/sheets/skill.generated.js +2 -7
- package/dist/providers/sheets/tools.js +734 -0
- package/dist/providers/slides/api.js +147 -0
- package/dist/providers/slides/index.d.ts +2 -10
- package/dist/providers/slides/index.js +48 -2555
- package/dist/providers/slides/lib/constants.d.ts +11 -0
- package/dist/providers/slides/lib/constants.js +11 -0
- package/dist/providers/slides/lib/requests.js +60 -0
- package/dist/providers/slides/lib/schemas.js +565 -0
- package/dist/providers/slides/openapi.generated.js +18 -0
- package/dist/providers/slides/skill.generated.js +2 -6
- package/dist/providers/slides/tools.js +1968 -0
- package/dist/providers/stripe/api.js +401 -0
- package/dist/providers/stripe/index.d.ts +7 -27
- package/dist/providers/stripe/index.js +75 -191
- package/dist/providers/stripe/lib/config.d.ts +4 -0
- package/dist/providers/stripe/lib/config.js +2 -0
- package/dist/providers/stripe/openapi.generated.js +4 -3
- package/dist/providers/stripe/{value-safety.js → safety.js} +5 -5
- package/dist/providers/stripe/skill.generated.js +2 -10
- package/dist/providers/stripe/tools.js +77 -0
- package/dist/providers/tithely/api.js +531 -0
- package/dist/providers/tithely/index.d.ts +19 -0
- package/dist/providers/tithely/index.js +86 -965
- package/dist/providers/tithely/skill.generated.js +2 -5
- package/dist/providers/tithely/tools.js +426 -0
- package/dist/providers/vercel/api.js +551 -0
- package/dist/providers/vercel/index.d.ts +11 -25
- package/dist/providers/vercel/index.js +67 -261
- package/dist/providers/vercel/lib/config.d.ts +2 -0
- package/dist/providers/vercel/lib/config.js +2 -0
- package/dist/providers/vercel/lib/index.js +117 -0
- package/dist/providers/vercel/lib/logs.js +100 -0
- package/dist/providers/vercel/lib/pin.js +270 -0
- package/dist/providers/vercel/openapi.generated.js +398 -376
- package/dist/providers/vercel/{value-safety.js → safety.js} +261 -42
- package/dist/providers/vercel/skill.generated.js +2 -7
- package/dist/providers/vercel/tools.js +642 -0
- package/dist/redaction-identity.js +47 -0
- package/dist/redaction-marker.js +125 -0
- package/dist/registry.d.ts +30 -28
- package/dist/registry.js +446 -413
- package/dist/routes/access-tokens.js +1 -1
- package/dist/routes/actor-labels.js +1 -1
- package/dist/routes/connect.js +1 -1
- package/dist/routes/dispatch.js +103 -0
- package/dist/routes/mcp.js +170 -79
- package/dist/routes/oauth.js +3 -1
- package/dist/routes/operator.js +2 -2
- package/dist/routes/shared.d.ts +5 -4
- package/dist/routes/shared.js +244 -177
- package/dist/routes/skills.js +10 -8
- package/dist/routes/ui-config.js +27 -3
- package/dist/routes/ui-data.js +29 -22
- package/dist/routes/ui.js +1 -1
- package/dist/runtime/call-admission.js +0 -26
- package/dist/runtime/call-entry.js +30 -0
- package/dist/runtime/call.js +556 -0
- package/dist/runtime/connector-scope.js +7 -0
- package/dist/runtime/credential-probe.js +34 -0
- package/dist/runtime/executor.js +40 -0
- package/dist/runtime/native-storage.js +63 -0
- package/dist/runtime/registry.js +27 -0
- package/dist/runtime/request-scope.js +72 -0
- package/dist/runtime/run.js +15 -7
- package/dist/runtime/shared-read.js +34 -5
- package/dist/runtime/storage.js +34 -21
- package/dist/sent-secrets.d.ts +194 -37
- package/dist/sent-secrets.js +1743 -340
- package/dist/server.js +5 -56
- package/dist/skills.js +330 -224
- package/dist/sqlite.js +15 -14
- package/dist/storage/d1-copy.js +255 -0
- package/dist/storage/d1-driver.js +15 -0
- package/dist/storage/keys.js +28 -0
- package/dist/storage/sql-driver.js +88 -0
- package/dist/storage/sql-native.js +544 -0
- package/dist/storage/sql-schema.d.ts +6 -0
- package/dist/storage/sql-schema.js +15 -0
- package/dist/storage/sql.d.ts +14 -20
- package/dist/storage/sql.js +29 -535
- package/dist/timeout.js +0 -26
- package/dist/tool-safety.js +20 -10
- package/dist/types.d.ts +11 -99
- package/dist/upward.js +3 -0
- package/dist/usage-guide.js +3 -1
- package/dist/validate.d.ts +4 -2
- package/dist/validate.js +86 -9
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.js +312 -292
- package/dist/workspace-subject.js +5 -1
- package/dist/write-outcome.js +454 -34
- package/documentation/README.md +27 -7
- package/documentation/architecture.md +2550 -186
- package/documentation/auth.md +821 -98
- package/documentation/code-mode.md +56 -21
- package/documentation/deploying.md +164 -20
- package/documentation/hosted-mcp-migration.md +460 -0
- package/documentation/integrating.md +144 -105
- package/documentation/meta-tools.md +411 -34
- package/documentation/operating.md +28 -4
- package/documentation/operator-tests.md +11 -2
- package/documentation/operator-ui.md +103 -33
- package/documentation/provider-auth-migration.md +38 -63
- package/documentation/provider-migration-0.29.md +10 -5
- package/documentation/provider-migration-0.35.md +975 -0
- package/documentation/providers.md +965 -0
- package/examples/worker/AGENTS.md +23 -9
- package/examples/worker/README.md +254 -135
- package/examples/worker/alchemy.run.ts +116 -0
- package/examples/worker/gitignore +10 -0
- package/examples/worker/src/connecta.config.ts +23 -21
- package/examples/worker/wrangler.jsonc +14 -5
- package/guides/connecting-agents.md +157 -0
- package/guides/how-it-works.md +156 -0
- package/guides/safety-and-trust.md +153 -0
- package/package.json +27 -42
- package/templates/node/.env.example +3 -0
- package/templates/node/README.md +22 -2
- package/templates/node/docker-compose.yml +1 -0
- package/templates/node/package.json +3 -2
- package/templates/node/src/connecta.config.ts +8 -0
- package/dist/catalog-drift.d.ts +0 -134
- package/dist/catalog-drift.js +0 -493
- package/dist/providers/_shared/request-token.js +0 -83
- package/dist/providers/_shared/rest/operation-index.d.ts +0 -133
- package/dist/providers/_shared/rest/tools.d.ts +0 -141
- package/dist/providers/_shared/rest/value-safety.d.ts +0 -201
- package/dist/providers/basecamp/index.d.ts +0 -43
- package/dist/providers/basecamp/index.js +0 -997
- package/dist/providers/basecamp/skill.generated.js +0 -9
- package/dist/providers/cloudflare/rest.d.ts +0 -37
- package/dist/providers/cloudflare/rest.js +0 -1078
- package/dist/providers/github/hosted.js +0 -218
- package/dist/providers/github/rest.js +0 -321
- package/dist/providers/infisical/index.d.ts +0 -7
- package/dist/providers/infisical/index.js +0 -713
- package/dist/providers/infisical/skill.generated.js +0 -8
- package/dist/providers/linear/index.d.ts +0 -55
- package/dist/providers/linear/index.js +0 -352
- package/dist/providers/linear/skill.generated.js +0 -8
- package/dist/providers/mixpanel/index.d.ts +0 -57
- package/dist/providers/mixpanel/index.js +0 -468
- package/dist/providers/mixpanel/operations.js +0 -20
- package/dist/providers/mixpanel/rest.js +0 -603
- package/dist/providers/mixpanel/skill.generated.js +0 -10
- package/dist/providers/mixpanel/value-safety.js +0 -16
- package/dist/providers/notion/index.d.ts +0 -65
- package/dist/providers/notion/index.js +0 -368
- package/dist/providers/notion/openapi.generated.js +0 -75
- package/dist/providers/notion/rest.d.ts +0 -24
- package/dist/providers/notion/rest.js +0 -1226
- package/dist/providers/notion/skill.generated.js +0 -11
- package/dist/providers/notion/value-safety.js +0 -56
- package/dist/providers/revenuecat/index.d.ts +0 -49
- package/dist/providers/revenuecat/index.js +0 -557
- package/dist/providers/revenuecat/skill.generated.js +0 -11
- package/dist/providers/stripe/rest.d.ts +0 -14
- package/dist/providers/stripe/rest.js +0 -402
- package/dist/providers/vercel/rest.d.ts +0 -20
- package/dist/providers/vercel/rest.js +0 -1020
- package/dist/providers/wispr-flow/index.d.ts +0 -14
- package/dist/providers/wispr-flow/index.js +0 -117
- package/dist/providers/wispr-flow/skill.generated.js +0 -8
- /package/dist/providers/github/{key.js → lib/key.js} +0 -0
|
@@ -0,0 +1,1435 @@
|
|
|
1
|
+
// Curated Gmail workflows over real Effect declarations and the standard kit boundary.
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
import { defined } from "../../connectors/api-connector.js";
|
|
4
|
+
import { ConnectorCallError } from "../../errors.js";
|
|
5
|
+
import { Refusal, toConnectorCallError } from "../../http/errors.js";
|
|
6
|
+
import { googleClient } from "../_shared/google/kit.js";
|
|
7
|
+
import { RESULT_BUDGET_BYTES, clampText, jsonBytes } from "../_shared/google/result-size.js";
|
|
8
|
+
import { encodeForAgent, redactBeforeCut, redactSentSecrets } from "../../sent-secrets.js";
|
|
9
|
+
import { errors, GMAIL, gmailBodyProvenance } from "./api.js";
|
|
10
|
+
import { read, write } from "../../http/tools.js";
|
|
11
|
+
import { boundCursor } from "../../http/paging.js";
|
|
12
|
+
import { fieldsProblem, selectFields } from "../../http/fields.js";
|
|
13
|
+
import { Operation } from "../../runtime/call.js";
|
|
14
|
+
import { index } from "./lib/index.js";
|
|
15
|
+
import { inputs, outputs, rows } from "./lib/schemas.js";
|
|
16
|
+
import { threadingIds } from "./lib/threading.js";
|
|
17
|
+
/**
|
|
18
|
+
* Each summary row costs one metadata read, so a page stays small. Gmail's
|
|
19
|
+
* own list maximum is 500.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_PAGE_SIZE = 25;
|
|
22
|
+
const DEFAULT_PAGE_SIZE = 10;
|
|
23
|
+
/** Bodies are capped per message; a thread multiplies this. */
|
|
24
|
+
const DEFAULT_THREAD_BODY_CHARS = 3000;
|
|
25
|
+
const DEFAULT_MESSAGE_BODY_CHARS = 12000;
|
|
26
|
+
/** Summary reads in flight at once for one page. */
|
|
27
|
+
const SUMMARY_CONCURRENCY = 5;
|
|
28
|
+
/** A full thread without attachment bodies; Gmail's message cap is 25 MB. */
|
|
29
|
+
const MAX_RECIPIENTS = 100;
|
|
30
|
+
/** Gmail cannot range-read attachment JSON, so text and binary downloads are bounded. */
|
|
31
|
+
const MAX_ATTACHMENT_BYTES = 1024 * 1024;
|
|
32
|
+
/** Same direct-call ceiling and minimum result budget as Drive content reads. */
|
|
33
|
+
const MAX_ATTACHMENT_RESULT_BYTES = 4 * 1024 * 1024;
|
|
34
|
+
function asRecord(value) {
|
|
35
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
36
|
+
}
|
|
37
|
+
function asArray(value) {
|
|
38
|
+
return Array.isArray(value) ? value : [];
|
|
39
|
+
}
|
|
40
|
+
function compact(value) {
|
|
41
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
42
|
+
}
|
|
43
|
+
function text(value) {
|
|
44
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
45
|
+
}
|
|
46
|
+
function header(payload, name) {
|
|
47
|
+
const wanted = name.toLowerCase();
|
|
48
|
+
for (const entry of asArray(payload["headers"])) {
|
|
49
|
+
if (entry?.name?.toLowerCase() === wanted)
|
|
50
|
+
return text(entry.value);
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const ENTITIES = {
|
|
55
|
+
amp: "&",
|
|
56
|
+
lt: "<",
|
|
57
|
+
gt: ">",
|
|
58
|
+
quot: '"',
|
|
59
|
+
apos: "'",
|
|
60
|
+
nbsp: " ",
|
|
61
|
+
};
|
|
62
|
+
function decodeEntities(ctx, value) {
|
|
63
|
+
const safe = redactBeforeCut(ctx, value);
|
|
64
|
+
const decoded = safe.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
|
|
65
|
+
if (name[0] === "#") {
|
|
66
|
+
const code = name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
|
|
67
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
|
|
68
|
+
}
|
|
69
|
+
return ENTITIES[name.toLowerCase()] ?? entity;
|
|
70
|
+
});
|
|
71
|
+
return redactBeforeCut(ctx, decoded);
|
|
72
|
+
}
|
|
73
|
+
/** Readable text from an HTML part that has no plain-text sibling. */
|
|
74
|
+
function htmlToText(ctx, html) {
|
|
75
|
+
html = redactBeforeCut(ctx, html);
|
|
76
|
+
return decodeEntities(ctx, html
|
|
77
|
+
.replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, "")
|
|
78
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
79
|
+
.replace(/<li\b[^>]*>/gi, "\n- ")
|
|
80
|
+
.replace(/<\/(p|div|tr|li|h[1-6]|blockquote|table)\s*>/gi, "\n")
|
|
81
|
+
.replace(/<[^>]*>/g, ""))
|
|
82
|
+
.replace(/[ \t\u00a0]+\n/g, "\n")
|
|
83
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
function base64UrlBytes(data) {
|
|
87
|
+
const base64 = data.replace(/-/g, "+").replace(/_/g, "/");
|
|
88
|
+
const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
|
|
89
|
+
const bytes = new Uint8Array(binary.length);
|
|
90
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
91
|
+
bytes[index] = binary.charCodeAt(index);
|
|
92
|
+
}
|
|
93
|
+
return bytes;
|
|
94
|
+
}
|
|
95
|
+
function charsetOf(ctx, part) {
|
|
96
|
+
const type = header(part, "Content-Type") ?? "";
|
|
97
|
+
if (redactBeforeCut(ctx, type) !== type)
|
|
98
|
+
return "[redacted]";
|
|
99
|
+
// Consume complete parameters so a semicolon inside another quoted value
|
|
100
|
+
// cannot masquerade as a charset. MIME allows whitespace between tokens.
|
|
101
|
+
const parameters = /;\s*([^=;\s]+)\s*=\s*(?:"((?:\\.|[^"\\])*)"|([^;\s]*))/g;
|
|
102
|
+
for (const match of type.matchAll(parameters)) {
|
|
103
|
+
if (match[1].toLowerCase() === "charset") {
|
|
104
|
+
const source = match[2] ?? match[3] ?? "";
|
|
105
|
+
if (redactBeforeCut(ctx, source) !== source)
|
|
106
|
+
return "[redacted]";
|
|
107
|
+
return redactBeforeCut(ctx, source.replace(/\\(.)/g, "$1")).trim();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return "utf-8";
|
|
111
|
+
}
|
|
112
|
+
/** Bytes as text, with an explicit notice when the declared charset is unsupported. */
|
|
113
|
+
function decodeIn(ctx, part, data) {
|
|
114
|
+
let bytes;
|
|
115
|
+
try {
|
|
116
|
+
bytes = base64UrlBytes(data);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { text: "" };
|
|
120
|
+
}
|
|
121
|
+
return decodeBytes(ctx, part, bytes);
|
|
122
|
+
}
|
|
123
|
+
function decodeBytes(ctx, part, bytes) {
|
|
124
|
+
const charset = charsetOf(ctx, part);
|
|
125
|
+
try {
|
|
126
|
+
return { text: new TextDecoder(charset).decode(bytes) };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return {
|
|
130
|
+
text: new TextDecoder().decode(bytes),
|
|
131
|
+
note: `Gmail declared unsupported charset "${clipped(ctx, charset, FIELD_BYTES)}"; decoded as UTF-8.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function mimeOf(part) {
|
|
136
|
+
return String(part["mimeType"] ?? "").toLowerCase();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Where the message's own content ends and something it carries begins: a
|
|
140
|
+
* named file, a part disposed as an attachment, or a whole forwarded message.
|
|
141
|
+
* Everything beneath such a part belongs to it — an attached email's text is
|
|
142
|
+
* not the message's body.
|
|
143
|
+
*/
|
|
144
|
+
function isAttachment(part) {
|
|
145
|
+
const mime = mimeOf(part);
|
|
146
|
+
return (Boolean(text(part["filename"])) ||
|
|
147
|
+
/^\s*attachment\b/i.test(header(part, "Content-Disposition") ?? "") ||
|
|
148
|
+
mime === "message/rfc822" ||
|
|
149
|
+
// Any leaf that is not body text — an unnamed inline image, a calendar
|
|
150
|
+
// invitation — is carried content, listed rather than dropped. A text
|
|
151
|
+
// body Gmail stored apart (an attachmentId, no filename) stays a body.
|
|
152
|
+
(mime !== "" && !mime.startsWith("multipart/") && !BODY_TYPES.has(mime)));
|
|
153
|
+
}
|
|
154
|
+
/** The only parts that can be a message's own body. */
|
|
155
|
+
const BODY_TYPES = new Set(["text/plain", "text/html"]);
|
|
156
|
+
/** How deep a payload is walked; deeper than this is refused, not guessed. */
|
|
157
|
+
const MAX_PART_DEPTH = 20;
|
|
158
|
+
/**
|
|
159
|
+
* Walk a payload keeping ancestry: the message's own parts, in order, and the
|
|
160
|
+
* attachment roots, whose subtrees are entered only for a resolved forwarded root.
|
|
161
|
+
*/
|
|
162
|
+
function walk(root, enterAttachmentRoot = false) {
|
|
163
|
+
const own = [];
|
|
164
|
+
const attachments = [];
|
|
165
|
+
const visit = (part, depth) => {
|
|
166
|
+
if (isAttachment(part) && !(enterAttachmentRoot && depth === 0)) {
|
|
167
|
+
attachments.push(part);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
own.push(part);
|
|
171
|
+
if (depth >= MAX_PART_DEPTH)
|
|
172
|
+
return;
|
|
173
|
+
for (const child of asArray(part["parts"]))
|
|
174
|
+
visit(asRecord(child), depth + 1);
|
|
175
|
+
};
|
|
176
|
+
visit(root, 0);
|
|
177
|
+
return { own, attachments };
|
|
178
|
+
}
|
|
179
|
+
/** The same plain-first selection for messages and parsed forwarded attachments. */
|
|
180
|
+
function ownBody(own) {
|
|
181
|
+
return own.find((part) => mimeOf(part) === "text/plain") ?? own.find((part) => mimeOf(part) === "text/html");
|
|
182
|
+
}
|
|
183
|
+
/** Gmail stores a large body apart from the message, behind an attachment id. */
|
|
184
|
+
const MAX_STORED_BODY_BYTES = 1024 * 1024;
|
|
185
|
+
/**
|
|
186
|
+
* The message's own text: its first plain part, else its first HTML part as
|
|
187
|
+
* text. A body Gmail stored apart from the message is fetched from the
|
|
188
|
+
* attachments endpoint when it is small enough to read; otherwise the result
|
|
189
|
+
* says so in `bodyFormat` and in the text, never as a silent empty body.
|
|
190
|
+
*/
|
|
191
|
+
function readBody(client, ctx, messageId, payload, max, step) {
|
|
192
|
+
return Effect.gen(function* () {
|
|
193
|
+
const own = walk(payload).own;
|
|
194
|
+
const chosen = ownBody(own);
|
|
195
|
+
if (!chosen)
|
|
196
|
+
return { body: "", bodyTruncated: false, bodyFormat: "none" };
|
|
197
|
+
const format = mimeOf(chosen) === "text/plain" ? "text" : "html";
|
|
198
|
+
const stored = asRecord(chosen["body"]);
|
|
199
|
+
let data = text(stored["data"]);
|
|
200
|
+
const attachmentId = text(stored["attachmentId"]);
|
|
201
|
+
if (!data && attachmentId) {
|
|
202
|
+
const size = typeof stored["size"] === "number" ? stored["size"] : undefined;
|
|
203
|
+
if (max === 0) {
|
|
204
|
+
return { body: "[… body not read; raise maxBodyChars to read it]", bodyTruncated: true, bodyFormat: format };
|
|
205
|
+
}
|
|
206
|
+
if (!messageId || size === undefined || size > MAX_STORED_BODY_BYTES) {
|
|
207
|
+
return {
|
|
208
|
+
body: `[Body of ${size ?? "unknown"} bytes is stored apart from the message and is larger than this connection reads (${MAX_STORED_BODY_BYTES} bytes); open it in Gmail.]`,
|
|
209
|
+
bodyTruncated: true,
|
|
210
|
+
bodyFormat: "unavailable",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const fetched = asRecord(yield* client.json({
|
|
214
|
+
method: "GET",
|
|
215
|
+
path: `/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
|
|
216
|
+
template: "/messages/{messageId}/attachments/{id}",
|
|
217
|
+
}, { step }));
|
|
218
|
+
data = text(fetched["data"]);
|
|
219
|
+
}
|
|
220
|
+
const decoded = data ? decodeIn(ctx, chosen, data) : { text: "" };
|
|
221
|
+
// Uncut: the caller caps it, by characters and by the result's bytes.
|
|
222
|
+
return {
|
|
223
|
+
text: format === "html" ? htmlToText(ctx, decoded.text) : decoded.text,
|
|
224
|
+
bodyFormat: format,
|
|
225
|
+
...(decoded.note ? { bodyNote: decoded.note } : {}),
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Gmail's ids are short opaque tokens — message and thread ids are sixteen
|
|
231
|
+
* hex digits, label ids `INBOX` or `Label_123`, attachment ids a few hundred
|
|
232
|
+
* characters. One far past that is not an id this connection can use. It is
|
|
233
|
+
* dropped, never cut — a cut id names nothing — and the drop is said out loud:
|
|
234
|
+
* an object lists the fields it omitted in `omittedIds`, and a list counts
|
|
235
|
+
* what it left out, so a missing id is never mistaken for an absent one.
|
|
236
|
+
*/
|
|
237
|
+
const ID_BYTES = 256;
|
|
238
|
+
const LABEL_ID_BYTES = 128;
|
|
239
|
+
const ATTACHMENT_ID_BYTES = 1024;
|
|
240
|
+
function idOf(value, maxBytes = ID_BYTES) {
|
|
241
|
+
const id = text(value);
|
|
242
|
+
return id !== undefined && jsonBytes(id) <= maxBytes ? id : undefined;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* The usable ids among `fields`, plus `omittedIds` naming each field Gmail
|
|
246
|
+
* sent an id for that was too long to return.
|
|
247
|
+
*/
|
|
248
|
+
function ids(fields) {
|
|
249
|
+
const kept = {};
|
|
250
|
+
const omitted = [];
|
|
251
|
+
for (const [name, [value, maxBytes]] of Object.entries(fields)) {
|
|
252
|
+
const id = name === "partId" ? partIdOf(value) : idOf(value, maxBytes);
|
|
253
|
+
if (id !== undefined)
|
|
254
|
+
kept[name] = id;
|
|
255
|
+
else if (text(value) !== undefined)
|
|
256
|
+
omitted.push(name);
|
|
257
|
+
}
|
|
258
|
+
return omitted.length > 0 ? { ...kept, omittedIds: omitted } : kept;
|
|
259
|
+
}
|
|
260
|
+
/** At most this many label ids per message; the rest are counted. */
|
|
261
|
+
const MAX_LISTED_LABELS = 20;
|
|
262
|
+
/** Usable label ids, at most twenty, and how many were left out either way. */
|
|
263
|
+
function labelsOf(labelIds) {
|
|
264
|
+
const usable = labelIds.filter((id) => idOf(id, LABEL_ID_BYTES) !== undefined);
|
|
265
|
+
const listed = usable.slice(0, MAX_LISTED_LABELS);
|
|
266
|
+
const omitted = labelIds.length - listed.length;
|
|
267
|
+
return omitted > 0 ? { labelIds: listed, labelIdsOmitted: omitted } : { labelIds: listed };
|
|
268
|
+
}
|
|
269
|
+
/** Attachment rows listed per message; the rest are counted, not listed. */
|
|
270
|
+
const MAX_LISTED_ATTACHMENTS = 20;
|
|
271
|
+
function attachmentsOf(ctx, payload) {
|
|
272
|
+
const all = walk(payload).attachments;
|
|
273
|
+
return {
|
|
274
|
+
listed: all.slice(0, MAX_LISTED_ATTACHMENTS).map((part) => {
|
|
275
|
+
const truncated = [];
|
|
276
|
+
const clip = clipper(ctx, truncated);
|
|
277
|
+
return compact({
|
|
278
|
+
filename: clip(text(part["filename"]) ?? "", FIELD_BYTES, "filename"),
|
|
279
|
+
mimeType: clip(text(part["mimeType"]), FIELD_BYTES, "mimeType"),
|
|
280
|
+
truncated,
|
|
281
|
+
size: typeof asRecord(part["body"])["size"] === "number" ? asRecord(part["body"])["size"] : undefined,
|
|
282
|
+
...ids({
|
|
283
|
+
attachmentId: [asRecord(part["body"])["attachmentId"], ATTACHMENT_ID_BYTES],
|
|
284
|
+
partId: [part["partId"]],
|
|
285
|
+
}),
|
|
286
|
+
});
|
|
287
|
+
}),
|
|
288
|
+
omitted: Math.max(0, all.length - MAX_LISTED_ATTACHMENTS),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** Unlike opaque ids, Gmail's root part id may be empty. */
|
|
292
|
+
function partIdOf(value) {
|
|
293
|
+
return typeof value === "string" && jsonBytes(value) <= ID_BYTES ? value : undefined;
|
|
294
|
+
}
|
|
295
|
+
function declaredSize(body) {
|
|
296
|
+
const size = body["size"];
|
|
297
|
+
return typeof size === "number" && Number.isSafeInteger(size) && size >= 0 ? size : undefined;
|
|
298
|
+
}
|
|
299
|
+
/** Include every descendant, even carried files that body selection will skip. */
|
|
300
|
+
function forwardedSize(part, depth = 0) {
|
|
301
|
+
if (depth > MAX_PART_DEPTH)
|
|
302
|
+
return undefined;
|
|
303
|
+
const body = asRecord(part["body"]);
|
|
304
|
+
const children = asArray(part["parts"]);
|
|
305
|
+
let total = declaredSize(body);
|
|
306
|
+
if (total === undefined) {
|
|
307
|
+
if (children.length === 0 || body["size"] !== undefined || body["attachmentId"] || body["data"])
|
|
308
|
+
return undefined;
|
|
309
|
+
total = 0;
|
|
310
|
+
}
|
|
311
|
+
for (const child of children) {
|
|
312
|
+
const size = forwardedSize(asRecord(child), depth + 1);
|
|
313
|
+
if (size === undefined)
|
|
314
|
+
return undefined;
|
|
315
|
+
total += size;
|
|
316
|
+
if (!Number.isSafeInteger(total))
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return total;
|
|
320
|
+
}
|
|
321
|
+
/** Validate both Gmail byte counts before content can be returned as complete. */
|
|
322
|
+
function attachmentBytes(client, ctx, messageId, part) {
|
|
323
|
+
return Effect.gen(function* () {
|
|
324
|
+
const stored = asRecord(part["body"]);
|
|
325
|
+
let source = stored;
|
|
326
|
+
const freshId = text(stored["attachmentId"]);
|
|
327
|
+
if (freshId) {
|
|
328
|
+
source = asRecord(yield* client.json({
|
|
329
|
+
method: "GET",
|
|
330
|
+
path: `/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(freshId)}`,
|
|
331
|
+
template: "/messages/{messageId}/attachments/{id}",
|
|
332
|
+
}, { step: "attachment" }));
|
|
333
|
+
}
|
|
334
|
+
const data = source["data"];
|
|
335
|
+
if (typeof data !== "string")
|
|
336
|
+
return undefined;
|
|
337
|
+
const provenance = gmailBodyProvenance(source);
|
|
338
|
+
const validateSize = (length) => {
|
|
339
|
+
if (provenance?.sizeValid === false ||
|
|
340
|
+
length !== declaredSize(stored) ||
|
|
341
|
+
(source["size"] !== undefined && length !== declaredSize(source)))
|
|
342
|
+
throw new ConnectorCallError("connector_call_failed", "Gmail attachment data does not match its declared byte size.", { retryable: false });
|
|
343
|
+
};
|
|
344
|
+
// Check the original bytes before decoding data that the privacy boundary may have changed.
|
|
345
|
+
if (provenance)
|
|
346
|
+
validateSize(provenance.originalLength);
|
|
347
|
+
let bytes;
|
|
348
|
+
try {
|
|
349
|
+
bytes = base64UrlBytes(data);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
throw new ConnectorCallError("connector_call_failed", "Gmail returned invalid base64url attachment data.", {
|
|
353
|
+
retryable: false,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (!provenance)
|
|
357
|
+
validateSize(bytes.length);
|
|
358
|
+
return { bytes, changed: provenance?.changed === true };
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
/** Read only a carried part of a fresh message, never an arbitrary download id. */
|
|
362
|
+
function readAttachment(client, ctx, args) {
|
|
363
|
+
return Effect.gen(function* () {
|
|
364
|
+
const messageId = String(args["messageId"]);
|
|
365
|
+
const partId = args["partId"];
|
|
366
|
+
const attachmentId = text(args["attachmentId"]);
|
|
367
|
+
if (partId === undefined && attachmentId === undefined) {
|
|
368
|
+
throw new ConnectorCallError("invalid_args", "Supply partId from the attachment listing, or attachmentId.");
|
|
369
|
+
}
|
|
370
|
+
const message = asRecord(yield* client.json({
|
|
371
|
+
method: "GET",
|
|
372
|
+
path: `/messages/${encodeURIComponent(messageId)}`,
|
|
373
|
+
template: "/messages/{id}",
|
|
374
|
+
query: { format: "full" },
|
|
375
|
+
}, { step: "message" }));
|
|
376
|
+
const attachments = walk(asRecord(message["payload"])).attachments;
|
|
377
|
+
// partId is immutable and authoritative when supplied. An attachmentId
|
|
378
|
+
// from the previous listing may have rotated since that messages.get.
|
|
379
|
+
const part = attachments.find((entry) => partId !== undefined ? entry["partId"] === partId : asRecord(entry["body"])["attachmentId"] === attachmentId);
|
|
380
|
+
if (!part) {
|
|
381
|
+
throw new ConnectorCallError("not_found", "This attachment is not among the message's carried parts. Read get_message again and use its immutable partId.");
|
|
382
|
+
}
|
|
383
|
+
const stored = asRecord(part["body"]);
|
|
384
|
+
const mime = mimeOf(part).split(";")[0].trim();
|
|
385
|
+
const parsedForward = mime === "message/rfc822" &&
|
|
386
|
+
!text(stored["attachmentId"]) &&
|
|
387
|
+
!text(stored["data"]) &&
|
|
388
|
+
asArray(part["parts"]).length > 0;
|
|
389
|
+
const size = parsedForward ? forwardedSize(part) : declaredSize(stored);
|
|
390
|
+
const maxBytes = typeof args["maxBytes"] === "number" ? args["maxBytes"] : RESULT_BUDGET_BYTES;
|
|
391
|
+
const maxChars = typeof args["maxChars"] === "number" ? args["maxChars"] : DEFAULT_MESSAGE_BODY_CHARS;
|
|
392
|
+
const truncated = [];
|
|
393
|
+
const clip = clipper(ctx, truncated);
|
|
394
|
+
const base = compact({
|
|
395
|
+
truncated,
|
|
396
|
+
messageId,
|
|
397
|
+
...ids({ partId: [part["partId"]] }),
|
|
398
|
+
filename: clip(text(part["filename"]) ?? "", FIELD_BYTES, "filename"),
|
|
399
|
+
mimeType: clip(text(part["mimeType"]) ?? "application/octet-stream", FIELD_BYTES, "mimeType"),
|
|
400
|
+
size,
|
|
401
|
+
});
|
|
402
|
+
const unavailable = (note) => ({ ...base, format: "unavailable", contentTruncated: false, note });
|
|
403
|
+
const isText = mime.startsWith("text/") ||
|
|
404
|
+
["application/json", "application/xml", "message/rfc822"].includes(mime) ||
|
|
405
|
+
mime.endsWith("+json") ||
|
|
406
|
+
mime.endsWith("+xml");
|
|
407
|
+
let envelope = { ...base, format: isText ? "text" : "base64", content: "", contentTruncated: false };
|
|
408
|
+
const fitting = Math.floor(Math.max(0, maxBytes - jsonBytes(envelope)) / 4) * 3;
|
|
409
|
+
const limit = isText ? MAX_ATTACHMENT_BYTES : Math.min(MAX_ATTACHMENT_BYTES, fitting);
|
|
410
|
+
const tooLarge = () => unavailable(limit < MAX_ATTACHMENT_BYTES
|
|
411
|
+
? `This binary attachment does not fit maxBytes (${maxBytes}), which holds ${limit} attachment bytes. Raise maxBytes, up to ${MAX_ATTACHMENT_RESULT_BYTES} for a direct call_tool only, to read up to ${MAX_ATTACHMENT_BYTES} bytes. No content is returned.`
|
|
412
|
+
: `This attachment exceeds the ${MAX_ATTACHMENT_BYTES}-byte download and inline cap. No content is returned; open it in Gmail.`);
|
|
413
|
+
// Declared size is checked before downloading, but actual bytes are checked
|
|
414
|
+
// too: inconsistent metadata must never bypass the cap or whole-result budget.
|
|
415
|
+
if (size === undefined)
|
|
416
|
+
return unavailable("Gmail did not declare a valid attachment size; no content is downloaded. Open it in Gmail.");
|
|
417
|
+
if (size > limit)
|
|
418
|
+
return tooLarge();
|
|
419
|
+
let decoded;
|
|
420
|
+
if (parsedForward) {
|
|
421
|
+
const own = walk(part, true).own;
|
|
422
|
+
const chosen = ownBody(own);
|
|
423
|
+
if (!chosen)
|
|
424
|
+
return unavailable("This forwarded message has no readable text body; open it in Gmail.");
|
|
425
|
+
const content = yield* attachmentBytes(client, ctx, messageId, chosen);
|
|
426
|
+
if (!content)
|
|
427
|
+
return unavailable("Gmail returned no readable forwarded message body data; open it in Gmail.");
|
|
428
|
+
const { bytes } = content;
|
|
429
|
+
const rendered = decodeBytes(ctx, chosen, bytes);
|
|
430
|
+
if (!rendered.text.trim())
|
|
431
|
+
return unavailable("This forwarded message has no readable text body; open it in Gmail.");
|
|
432
|
+
const headers = ["From", "To", "Date", "Subject"].flatMap((name) => {
|
|
433
|
+
const value = own.map((entry) => header(entry, name)).find((entry) => entry !== undefined);
|
|
434
|
+
return value === undefined ? [] : [`${name}: ${value}`];
|
|
435
|
+
});
|
|
436
|
+
const body = mimeOf(chosen) === "text/html" ? htmlToText(ctx, rendered.text) : rendered.text;
|
|
437
|
+
if (!body.trim())
|
|
438
|
+
return unavailable("This forwarded message has no readable text body; open it in Gmail.");
|
|
439
|
+
decoded = headers.length > 0 ? `${headers.join("\n")}\n\n${body}` : body;
|
|
440
|
+
envelope = { ...envelope, ...compact({ note: rendered.note }) };
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
const content = yield* attachmentBytes(client, ctx, messageId, part);
|
|
444
|
+
if (!content)
|
|
445
|
+
return unavailable("Gmail returned no attachment data; open it in Gmail.");
|
|
446
|
+
const { bytes, changed } = content;
|
|
447
|
+
if (bytes.length > limit)
|
|
448
|
+
return tooLarge();
|
|
449
|
+
if (!isText)
|
|
450
|
+
return { ...envelope, content: encodeForAgent(ctx, changed ? new TextEncoder().encode("[redacted]") : bytes) };
|
|
451
|
+
const rendered = decodeBytes(ctx, part, bytes);
|
|
452
|
+
decoded = rendered.text;
|
|
453
|
+
if (mime === "message/rfc822" && !decoded.trim()) {
|
|
454
|
+
return unavailable("This forwarded message has no readable text body; open it in Gmail.");
|
|
455
|
+
}
|
|
456
|
+
envelope = { ...envelope, ...compact({ note: rendered.note }) };
|
|
457
|
+
}
|
|
458
|
+
// Every secret the request sent is replaced before either cut below.
|
|
459
|
+
if (decoded.length > maxChars || jsonBytes(decoded) > maxBytes)
|
|
460
|
+
decoded = redactBeforeCut(ctx, decoded);
|
|
461
|
+
const characters = Array.from(decoded);
|
|
462
|
+
const marker = (dropped) => `\n[… ${dropped} more characters truncated; raise maxChars or maxBytes for a direct call_tool, or open it in Gmail]`;
|
|
463
|
+
const byChars = characters.length > maxChars
|
|
464
|
+
? characters.slice(0, maxChars).join("") + marker(characters.length - maxChars)
|
|
465
|
+
: decoded;
|
|
466
|
+
if (jsonBytes({ ...envelope, content: byChars }) <= maxBytes) {
|
|
467
|
+
return { ...envelope, content: byChars, contentTruncated: characters.length > maxChars };
|
|
468
|
+
}
|
|
469
|
+
// Reserve the longest marker, then clamp only the original prefix. A text
|
|
470
|
+
// cut by both limits still has one marker, counted in the result's budget.
|
|
471
|
+
const prefix = characters.slice(0, maxChars).join("");
|
|
472
|
+
const contentBudget = maxBytes - jsonBytes(envelope) + jsonBytes("");
|
|
473
|
+
const kept = clampText(ctx, prefix, contentBudget - jsonBytes(marker(characters.length)) + 2, () => "");
|
|
474
|
+
const content = kept + marker(characters.length - Array.from(kept).length);
|
|
475
|
+
return { ...envelope, content, contentTruncated: true };
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Per-field byte bounds, so every result is bounded by construction: a page
|
|
480
|
+
* of 25 summaries, or one message beside its body, stays under
|
|
481
|
+
* `RESULT_BUDGET_BYTES` however long and however encoded Google's strings are.
|
|
482
|
+
*/
|
|
483
|
+
const FIELD_BYTES = 512;
|
|
484
|
+
const HEADER_BYTES = 4 * 1024;
|
|
485
|
+
/** A string cut to `maxBytes` of JSON, ending in "…" when it was cut. */
|
|
486
|
+
function clipped(ctx, value, maxBytes) {
|
|
487
|
+
return (value === undefined ? value : clampText(ctx, value, maxBytes, () => "…"));
|
|
488
|
+
}
|
|
489
|
+
/** Per-record clipping provenance; redaction is not a size truncation. */
|
|
490
|
+
function clipper(ctx, cuts) {
|
|
491
|
+
return (value, max, path) => {
|
|
492
|
+
const result = clipped(ctx, value, max);
|
|
493
|
+
if (value !== undefined && result !== redactBeforeCut(ctx, value))
|
|
494
|
+
cuts.push(path);
|
|
495
|
+
return result;
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
/** Cut a body at `max` characters and say so in the text itself, after replacing every
|
|
499
|
+
* secret the request sent: a cut through an echo leaves a fragment nothing redacts. */
|
|
500
|
+
function capped(ctx, whole, max) {
|
|
501
|
+
if (whole.length <= max)
|
|
502
|
+
return { body: whole, bodyTruncated: false };
|
|
503
|
+
const body = redactBeforeCut(ctx, whole);
|
|
504
|
+
if (body.length <= max)
|
|
505
|
+
return { body, bodyTruncated: false };
|
|
506
|
+
const rest = body.length - max;
|
|
507
|
+
return {
|
|
508
|
+
body: `${body.slice(0, max)}\n[… ${rest} more characters truncated; raise maxBodyChars to read them]`,
|
|
509
|
+
bodyTruncated: true,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function isoFromMillis(value) {
|
|
513
|
+
const millis = typeof value === "string" ? Number(value) : value;
|
|
514
|
+
if (typeof millis !== "number" || !Number.isFinite(millis))
|
|
515
|
+
return undefined;
|
|
516
|
+
const date = new Date(millis);
|
|
517
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
518
|
+
}
|
|
519
|
+
function labelIdsOf(message) {
|
|
520
|
+
return asArray(message["labelIds"]).filter((id) => typeof id === "string");
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* One message, projected and finished into the result it belongs to, with
|
|
524
|
+
* its body cut so that the finished result — measured as it will be sent, not
|
|
525
|
+
* estimated — fits `budget`. `finish` shapes the message into what the tool
|
|
526
|
+
* returns (a draft's wrapper, say); the identity for a bare message.
|
|
527
|
+
*/
|
|
528
|
+
function projectMessage(client, ctx, value, maxBodyChars, budget = RESULT_BUDGET_BYTES, finish = (message) => message, bodyStep = "body") {
|
|
529
|
+
return Effect.gen(function* () {
|
|
530
|
+
const message = asRecord(value);
|
|
531
|
+
const payload = asRecord(message["payload"]);
|
|
532
|
+
const read = yield* readBody(client, ctx, idOf(message["id"]), payload, maxBodyChars, bodyStep);
|
|
533
|
+
const attachments = attachmentsOf(ctx, payload);
|
|
534
|
+
const truncated = [];
|
|
535
|
+
const clip = clipper(ctx, truncated);
|
|
536
|
+
const headerOf = (name, field = name.toLowerCase()) => clip(header(payload, name), HEADER_BYTES, field);
|
|
537
|
+
const { omittedIds, ...messageIds } = ids({ id: [message["id"]], threadId: [message["threadId"]] });
|
|
538
|
+
const projected = compact({
|
|
539
|
+
truncated,
|
|
540
|
+
...messageIds,
|
|
541
|
+
...labelsOf(labelIdsOf(message)),
|
|
542
|
+
date: isoFromMillis(message["internalDate"]),
|
|
543
|
+
from: headerOf("From"),
|
|
544
|
+
to: headerOf("To"),
|
|
545
|
+
cc: headerOf("Cc"),
|
|
546
|
+
bcc: headerOf("Bcc"),
|
|
547
|
+
replyTo: headerOf("Reply-To", "replyTo"),
|
|
548
|
+
subject: headerOf("Subject"),
|
|
549
|
+
messageIdHeader: headerOf("Message-ID", "messageIdHeader"),
|
|
550
|
+
inReplyTo: headerOf("In-Reply-To", "inReplyTo"),
|
|
551
|
+
snippet: text(message["snippet"])
|
|
552
|
+
? clip(decodeEntities(ctx, message["snippet"]), HEADER_BYTES, "snippet")
|
|
553
|
+
: undefined,
|
|
554
|
+
body: "",
|
|
555
|
+
bodyTruncated: false,
|
|
556
|
+
bodyFormat: read.bodyFormat,
|
|
557
|
+
bodyNote: read.bodyNote,
|
|
558
|
+
attachments: attachments.listed.length > 0 ? attachments.listed : undefined,
|
|
559
|
+
attachmentsOmitted: attachments.omitted > 0 ? attachments.omitted : undefined,
|
|
560
|
+
omittedIds,
|
|
561
|
+
});
|
|
562
|
+
if (!("text" in read))
|
|
563
|
+
return finish({ ...projected, body: read.body, bodyTruncated: read.bodyTruncated });
|
|
564
|
+
// Cap by the caller's characters, then by bytes: cut, measure the finished
|
|
565
|
+
// result, and cut again by exactly what it is over, until it fits. Every
|
|
566
|
+
// field is counted as sent — `false` is a byte longer than `true` — so a
|
|
567
|
+
// body at the exact boundary is not a byte over.
|
|
568
|
+
const byCharacters = capped(ctx, read.text, maxBodyChars);
|
|
569
|
+
const build = (body) => finish({ ...projected, body, bodyTruncated: byCharacters.bodyTruncated || body !== byCharacters.body });
|
|
570
|
+
let body = byCharacters.body;
|
|
571
|
+
let result = build(body);
|
|
572
|
+
for (let round = 0; round < 8; round += 1) {
|
|
573
|
+
const over = jsonBytes(result) - budget;
|
|
574
|
+
if (over <= 0)
|
|
575
|
+
return result;
|
|
576
|
+
const target = Math.max(2, jsonBytes(body) - over);
|
|
577
|
+
const shorter = clampText(ctx, byCharacters.body, target, resultLimitMarker);
|
|
578
|
+
if (shorter === body)
|
|
579
|
+
break;
|
|
580
|
+
body = shorter;
|
|
581
|
+
result = build(body);
|
|
582
|
+
}
|
|
583
|
+
// Nothing left to cut: the metadata alone is past the budget, and the
|
|
584
|
+
// tool's final size check refuses it rather than sending it.
|
|
585
|
+
return result;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/** Longest cursor this connection issues; the schemas accept no longer. */
|
|
589
|
+
const MAX_CURSOR_LENGTH = 2048;
|
|
590
|
+
async function digest(value) {
|
|
591
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)));
|
|
592
|
+
return Array.from(bytes.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
593
|
+
}
|
|
594
|
+
/** The digest naming one call: its tool and the arguments that shape its pages. */
|
|
595
|
+
function callKey(tool, shape) {
|
|
596
|
+
return digest(JSON.stringify([tool, shape]));
|
|
597
|
+
}
|
|
598
|
+
/** A cursor of connecta's own claims; Gmail's page token, its only downstream text, arrives redacted. */
|
|
599
|
+
function encodeCursor(ctx, claims) {
|
|
600
|
+
return encodeForAgent(ctx, JSON.stringify(claims), { url: true, claims: true });
|
|
601
|
+
}
|
|
602
|
+
/** The claims of a cursor this call may resume, or a refusal saying why not. */
|
|
603
|
+
function openCursor(cursor, tool, call) {
|
|
604
|
+
if (cursor === undefined)
|
|
605
|
+
return undefined;
|
|
606
|
+
let claims;
|
|
607
|
+
try {
|
|
608
|
+
const parsed = JSON.parse(new TextDecoder().decode(base64UrlBytes(String(cursor))));
|
|
609
|
+
if (parsed && typeof parsed === "object" && parsed.v === 1)
|
|
610
|
+
claims = parsed;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
claims = undefined;
|
|
614
|
+
}
|
|
615
|
+
if (!claims || typeof claims.t !== "string" || typeof claims.c !== "string") {
|
|
616
|
+
throw new ConnectorCallError("invalid_args", "That is not a cursor this connection issued. Start again without one.");
|
|
617
|
+
}
|
|
618
|
+
if (claims.t !== tool || claims.c !== call) {
|
|
619
|
+
throw new ConnectorCallError("invalid_args", "That cursor came from a different call — another tool, thread, query, or page size. Pass the cursor this same call returned, or start again without one.");
|
|
620
|
+
}
|
|
621
|
+
return claims;
|
|
622
|
+
}
|
|
623
|
+
/** Where to resume in `ids`, checked against what the earlier pages saw. */
|
|
624
|
+
async function resumeAt(ids, claims, what) {
|
|
625
|
+
if (!claims)
|
|
626
|
+
return 0;
|
|
627
|
+
const seen = claims.n;
|
|
628
|
+
if (typeof seen !== "number" ||
|
|
629
|
+
!Number.isInteger(seen) ||
|
|
630
|
+
seen < 1 ||
|
|
631
|
+
seen > ids.length ||
|
|
632
|
+
claims.f !== (await digest(JSON.stringify(ids.slice(0, seen))))) {
|
|
633
|
+
throw new ConnectorCallError("conflict", `The ${what} changed before the previous page ended. Read it again from the start, without a cursor.`);
|
|
634
|
+
}
|
|
635
|
+
return seen;
|
|
636
|
+
}
|
|
637
|
+
async function cursorAfter(ctx, ids, seen, tool, call) {
|
|
638
|
+
return encodeCursor(ctx, { v: 1, t: tool, c: call, n: seen, f: await digest(JSON.stringify(ids.slice(0, seen))) });
|
|
639
|
+
}
|
|
640
|
+
/** A page object whose cursor is as long as any this connection issues. */
|
|
641
|
+
const RESERVED_PAGE = { hasMore: true, nextCursor: "x".repeat(4096) };
|
|
642
|
+
function resultLimitMarker(dropped) {
|
|
643
|
+
return `\n[… ${dropped} more characters truncated at connecta's ${RESULT_BUDGET_BYTES / 1024} KiB per-result limit; read the rest in Gmail]`;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Every Gmail result's last check: its whole serialized size, wrapper and
|
|
647
|
+
* all, against the budget. The bounds above make a refusal here a
|
|
648
|
+
* should-not-happen; it exists so nothing oversized is ever handed on.
|
|
649
|
+
*/
|
|
650
|
+
function deliverable(tool, result, budget = RESULT_BUDGET_BYTES) {
|
|
651
|
+
const size = jsonBytes(result);
|
|
652
|
+
if (size > budget) {
|
|
653
|
+
throw new ConnectorCallError("connector_call_failed", `Gmail's answer to ${tool} is ${size} bytes even after cutting, past connecta's ${budget / 1024} KiB result limit. Open it in Gmail.`, { retryable: false });
|
|
654
|
+
}
|
|
655
|
+
return result;
|
|
656
|
+
}
|
|
657
|
+
function projectThreadSummary(ctx, value, listedSnippet) {
|
|
658
|
+
const truncated = [];
|
|
659
|
+
const clip = clipper(ctx, truncated);
|
|
660
|
+
const thread = asRecord(value);
|
|
661
|
+
const messages = asArray(thread["messages"]).map(asRecord);
|
|
662
|
+
const first = asRecord(messages[0]?.["payload"]);
|
|
663
|
+
const last = asRecord(messages[messages.length - 1]?.["payload"]);
|
|
664
|
+
const labels = new Set();
|
|
665
|
+
let latest;
|
|
666
|
+
for (const message of messages) {
|
|
667
|
+
for (const id of labelIdsOf(message))
|
|
668
|
+
labels.add(id);
|
|
669
|
+
const at = Number(message["internalDate"]);
|
|
670
|
+
if (Number.isFinite(at) && (latest === undefined || at > latest))
|
|
671
|
+
latest = at;
|
|
672
|
+
}
|
|
673
|
+
const snippet = text(listedSnippet) ?? text(messages[messages.length - 1]?.["snippet"]);
|
|
674
|
+
return compact({
|
|
675
|
+
truncated,
|
|
676
|
+
...ids({ id: [thread["id"]] }),
|
|
677
|
+
subject: clip(header(first, "Subject"), FIELD_BYTES, "subject"),
|
|
678
|
+
from: clip(header(first, "From"), FIELD_BYTES, "from"),
|
|
679
|
+
lastFrom: messages.length > 1 ? clip(header(last, "From"), FIELD_BYTES, "lastFrom") : undefined,
|
|
680
|
+
messageCount: messages.length,
|
|
681
|
+
lastMessageAt: isoFromMillis(latest),
|
|
682
|
+
unread: labels.has("UNREAD"),
|
|
683
|
+
...labelsOf([...labels]),
|
|
684
|
+
snippet: snippet ? clip(decodeEntities(ctx, snippet), FIELD_BYTES, "snippet") : undefined,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function projectDraftSummary(ctx, value) {
|
|
688
|
+
const truncated = [];
|
|
689
|
+
const clip = clipper(ctx, truncated);
|
|
690
|
+
const draft = asRecord(value);
|
|
691
|
+
const message = asRecord(draft["message"]);
|
|
692
|
+
const payload = asRecord(message["payload"]);
|
|
693
|
+
return compact({
|
|
694
|
+
truncated,
|
|
695
|
+
...ids({ draftId: [draft["id"]], messageId: [message["id"]], threadId: [message["threadId"]] }),
|
|
696
|
+
subject: clip(header(payload, "Subject"), FIELD_BYTES, "subject"),
|
|
697
|
+
to: clip(header(payload, "To"), FIELD_BYTES, "to"),
|
|
698
|
+
updatedAt: isoFromMillis(message["internalDate"]),
|
|
699
|
+
snippet: text(message["snippet"])
|
|
700
|
+
? clip(decodeEntities(ctx, message["snippet"]), FIELD_BYTES, "snippet")
|
|
701
|
+
: undefined,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
/** Run `work` over `items`, at most `limit` at a time, keeping order. */
|
|
705
|
+
function mapLimited(items, limit, work) {
|
|
706
|
+
return Effect.all(items.map(work), { concurrency: limit });
|
|
707
|
+
}
|
|
708
|
+
/** A summary lost between listing and reading is omitted, as on the legacy client. */
|
|
709
|
+
function unlessGone(read) {
|
|
710
|
+
return Effect.suspend(read).pipe(Effect.catch((failure) => (failure._tag === "StatusFailure" &&
|
|
711
|
+
toConnectorCallError(failure, errors, { classification: "read", dispatches: [] }).code === "not_found") ||
|
|
712
|
+
(failure._tag === "Refusal" && failure.error.code === "not_found")
|
|
713
|
+
? Effect.succeed(undefined)
|
|
714
|
+
: Effect.fail(failure)));
|
|
715
|
+
}
|
|
716
|
+
// --- Writing drafts ---------------------------------------------------------------
|
|
717
|
+
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
|
|
718
|
+
const MAILBOX = /^[^\s@<>()",;:\\[\]]+@[^\s@<>()",;:\\[\]]+$/;
|
|
719
|
+
function refuseLineBreaks(name, value) {
|
|
720
|
+
if (/[\r\n]/.test(value) || value.includes("\u0000")) {
|
|
721
|
+
throw new ConnectorCallError("invalid_args", `${name} may not contain a line break.`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* RFC 2047 encoded words for header text that is not plain ASCII. Each word
|
|
726
|
+
* carries at most 45 UTF-8 bytes (60 base64 characters, 72 with its armor),
|
|
727
|
+
* split only between code points, and words fold onto continuation lines.
|
|
728
|
+
*/
|
|
729
|
+
function encodedWords(value) {
|
|
730
|
+
if (PRINTABLE_ASCII.test(value))
|
|
731
|
+
return value;
|
|
732
|
+
const encoder = new TextEncoder();
|
|
733
|
+
const words = [];
|
|
734
|
+
let chunk = "";
|
|
735
|
+
let size = 0;
|
|
736
|
+
for (const character of value) {
|
|
737
|
+
const bytes = encoder.encode(character).length;
|
|
738
|
+
if (size + bytes > 45 && chunk) {
|
|
739
|
+
words.push(chunk);
|
|
740
|
+
chunk = "";
|
|
741
|
+
size = 0;
|
|
742
|
+
}
|
|
743
|
+
chunk += character;
|
|
744
|
+
size += bytes;
|
|
745
|
+
}
|
|
746
|
+
if (chunk)
|
|
747
|
+
words.push(chunk);
|
|
748
|
+
return words.map((word) => `=?UTF-8?B?${base64(encoder.encode(word))}?=`).join("\r\n ");
|
|
749
|
+
}
|
|
750
|
+
/** `Name <addr>` or `addr`, display name encoded or quoted as needed. */
|
|
751
|
+
function formatAddress(field, raw) {
|
|
752
|
+
refuseLineBreaks(field, raw);
|
|
753
|
+
const trimmed = raw.trim();
|
|
754
|
+
const named = /^(.*?)\s*<([^<>]+)>$/.exec(trimmed);
|
|
755
|
+
const address = (named ? named[2] : trimmed).trim();
|
|
756
|
+
if (!MAILBOX.test(address) || !PRINTABLE_ASCII.test(address)) {
|
|
757
|
+
throw new ConnectorCallError("invalid_args", `${field} has an entry that is not an email address; use "name@example.com" or "Name <name@example.com>".`);
|
|
758
|
+
}
|
|
759
|
+
const display = named?.[1]?.trim() ?? "";
|
|
760
|
+
const quoted = /^"((?:[^"\\]|\\.)*)"$/.exec(display);
|
|
761
|
+
const name = (quoted ? quoted[1].replace(/\\(.)/g, "$1") : display).trim();
|
|
762
|
+
if (!name)
|
|
763
|
+
return address;
|
|
764
|
+
if (!PRINTABLE_ASCII.test(name))
|
|
765
|
+
return `${encodedWords(name)} <${address}>`;
|
|
766
|
+
return /^[A-Za-z0-9 !#$%&'*+\-/=?^_`{|}~]+$/.test(name)
|
|
767
|
+
? `${name} <${address}>`
|
|
768
|
+
: `"${name.replace(/(["\\])/g, "\\$1")}" <${address}>`;
|
|
769
|
+
}
|
|
770
|
+
function base64(bytes) {
|
|
771
|
+
let binary = "";
|
|
772
|
+
for (let index = 0; index < bytes.length; index += 0x8000) {
|
|
773
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
774
|
+
}
|
|
775
|
+
return btoa(binary);
|
|
776
|
+
}
|
|
777
|
+
/** Base64 body lines of 76 characters, as RFC 2045 requires. */
|
|
778
|
+
function base64Lines(value) {
|
|
779
|
+
return (base64(new TextEncoder().encode(value)).match(/.{1,76}/g) ?? []).join("\r\n");
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Split an address header into its mailboxes, as RFC 5322 reads it: commas
|
|
783
|
+
* inside a quoted display name, an angle address, or a comment do not
|
|
784
|
+
* separate, comments are dropped, and a group (`Team: a@x, b@y;`) contributes
|
|
785
|
+
* its members. Each mailbox is then checked; a header that does not parse is
|
|
786
|
+
* refused rather than guessed at, because a wrong split sends mail to the
|
|
787
|
+
* wrong people.
|
|
788
|
+
*/
|
|
789
|
+
function addressEntries(field, value) {
|
|
790
|
+
if (!value)
|
|
791
|
+
return [];
|
|
792
|
+
const entries = [];
|
|
793
|
+
let current = "";
|
|
794
|
+
let quoted = false;
|
|
795
|
+
let escaped = false;
|
|
796
|
+
let angle = 0;
|
|
797
|
+
let comment = 0;
|
|
798
|
+
let group = false;
|
|
799
|
+
const flush = () => {
|
|
800
|
+
const entry = current.replace(/\s+/g, " ").trim();
|
|
801
|
+
if (entry)
|
|
802
|
+
entries.push(entry);
|
|
803
|
+
current = "";
|
|
804
|
+
};
|
|
805
|
+
for (const character of value) {
|
|
806
|
+
if (escaped) {
|
|
807
|
+
current += character;
|
|
808
|
+
escaped = false;
|
|
809
|
+
}
|
|
810
|
+
else if (quoted) {
|
|
811
|
+
if (character === "\\")
|
|
812
|
+
escaped = true;
|
|
813
|
+
else if (character === '"')
|
|
814
|
+
quoted = false;
|
|
815
|
+
current += character;
|
|
816
|
+
}
|
|
817
|
+
else if (comment > 0) {
|
|
818
|
+
if (character === "(")
|
|
819
|
+
comment += 1;
|
|
820
|
+
else if (character === ")")
|
|
821
|
+
comment -= 1;
|
|
822
|
+
}
|
|
823
|
+
else if (character === '"') {
|
|
824
|
+
quoted = true;
|
|
825
|
+
current += character;
|
|
826
|
+
}
|
|
827
|
+
else if (character === "(") {
|
|
828
|
+
comment = 1;
|
|
829
|
+
}
|
|
830
|
+
else if (character === "<") {
|
|
831
|
+
angle += 1;
|
|
832
|
+
current += character;
|
|
833
|
+
}
|
|
834
|
+
else if (character === ">") {
|
|
835
|
+
angle -= 1;
|
|
836
|
+
current += character;
|
|
837
|
+
}
|
|
838
|
+
else if (angle === 0 && character === ":" && !group) {
|
|
839
|
+
// A group's display name names nobody; its members follow.
|
|
840
|
+
group = true;
|
|
841
|
+
current = "";
|
|
842
|
+
}
|
|
843
|
+
else if (angle === 0 && character === ";" && group) {
|
|
844
|
+
group = false;
|
|
845
|
+
flush();
|
|
846
|
+
}
|
|
847
|
+
else if (angle === 0 && character === ",") {
|
|
848
|
+
flush();
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
current += character;
|
|
852
|
+
}
|
|
853
|
+
if (angle < 0 || angle > 1)
|
|
854
|
+
break;
|
|
855
|
+
}
|
|
856
|
+
if (quoted || escaped || angle !== 0 || comment !== 0 || group) {
|
|
857
|
+
throw new ConnectorCallError("invalid_args", `${field} is not a well-formed address list.`);
|
|
858
|
+
}
|
|
859
|
+
flush();
|
|
860
|
+
// Validate each one now, with this field's name, before anything is built.
|
|
861
|
+
for (const entry of entries)
|
|
862
|
+
formatAddress(field, entry);
|
|
863
|
+
return entries;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* One RFC 5322 message, base64url-encoded as Gmail's `raw` field wants it.
|
|
867
|
+
* `Date` and `Message-ID` are Gmail's to set, and so is `From` on a new draft:
|
|
868
|
+
* Gmail fills it with the mailbox's own address. An update keeps the `From`
|
|
869
|
+
* the draft already had — a send-as alias the user chose in Gmail — and no
|
|
870
|
+
* argument can set one, so an agent cannot forge a sender.
|
|
871
|
+
*/
|
|
872
|
+
function buildRawMessage(message) {
|
|
873
|
+
const lines = [];
|
|
874
|
+
for (const [name, list] of [
|
|
875
|
+
["From", message.from],
|
|
876
|
+
["To", message.to],
|
|
877
|
+
["Cc", message.cc],
|
|
878
|
+
["Bcc", message.bcc],
|
|
879
|
+
["Reply-To", message.replyTo],
|
|
880
|
+
]) {
|
|
881
|
+
if (list.length === 0)
|
|
882
|
+
continue;
|
|
883
|
+
if (list.length > MAX_RECIPIENTS) {
|
|
884
|
+
throw new ConnectorCallError("invalid_args", `${name} lists more than ${MAX_RECIPIENTS} recipients.`);
|
|
885
|
+
}
|
|
886
|
+
lines.push(`${name}: ${list.map((entry) => formatAddress(name.toLowerCase(), entry)).join(",\r\n ")}`);
|
|
887
|
+
}
|
|
888
|
+
if (message.subject !== undefined) {
|
|
889
|
+
refuseLineBreaks("subject", message.subject);
|
|
890
|
+
lines.push(`Subject: ${encodedWords(message.subject)}`);
|
|
891
|
+
}
|
|
892
|
+
if (message.inReplyTo)
|
|
893
|
+
lines.push(`In-Reply-To: ${message.inReplyTo}`);
|
|
894
|
+
if (message.references)
|
|
895
|
+
lines.push(`References: ${message.references}`);
|
|
896
|
+
lines.push("MIME-Version: 1.0");
|
|
897
|
+
const plain = [
|
|
898
|
+
'Content-Type: text/plain; charset="UTF-8"',
|
|
899
|
+
"Content-Transfer-Encoding: base64",
|
|
900
|
+
"",
|
|
901
|
+
base64Lines(message.body),
|
|
902
|
+
];
|
|
903
|
+
if (message.htmlBody === undefined) {
|
|
904
|
+
lines.push(...plain);
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
const boundary = `connecta-${crypto.randomUUID()}`;
|
|
908
|
+
lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`, "", `--${boundary}`, ...plain, `--${boundary}`, 'Content-Type: text/html; charset="UTF-8"', "Content-Transfer-Encoding: base64", "", base64Lines(message.htmlBody), `--${boundary}--`);
|
|
909
|
+
}
|
|
910
|
+
return base64(new TextEncoder().encode(`${lines.join("\r\n")}\r\n`))
|
|
911
|
+
.replace(/\+/g, "-")
|
|
912
|
+
.replace(/\//g, "_")
|
|
913
|
+
.replace(/=+$/, "");
|
|
914
|
+
}
|
|
915
|
+
/** At most this many ancestors ride in References; the newest are kept. */
|
|
916
|
+
const MAX_REFERENCES = 20;
|
|
917
|
+
/** Threading for a reply: the answered message's thread, ids, and sender. */
|
|
918
|
+
function replyThreading(client, messageId) {
|
|
919
|
+
return Effect.gen(function* () {
|
|
920
|
+
const message = asRecord(yield* client.json({
|
|
921
|
+
method: "GET",
|
|
922
|
+
path: `/messages/${encodeURIComponent(messageId)}`,
|
|
923
|
+
template: "/messages/{id}",
|
|
924
|
+
query: {
|
|
925
|
+
format: "metadata",
|
|
926
|
+
metadataHeaders: ["Message-ID", "References", "Subject", "From", "Reply-To"],
|
|
927
|
+
},
|
|
928
|
+
}, { effect: "read", step: "reply" }));
|
|
929
|
+
const payload = asRecord(message["payload"]);
|
|
930
|
+
const id = threadingIds(header(payload, "Message-ID"))[0];
|
|
931
|
+
const chain = [...threadingIds(header(payload, "References"), true), ...(id ? [id] : [])].slice(-MAX_REFERENCES);
|
|
932
|
+
const subject = oneLine(header(payload, "Subject"));
|
|
933
|
+
const replyTo = header(payload, "Reply-To");
|
|
934
|
+
return {
|
|
935
|
+
threadId: text(message["threadId"]),
|
|
936
|
+
inReplyTo: id,
|
|
937
|
+
references: chain.length > 0 ? chain.join("\r\n ") : undefined,
|
|
938
|
+
subject: subject === undefined ? undefined : /^re:/i.test(subject) ? subject : `Re: ${subject}`,
|
|
939
|
+
// Parsed only when the caller did not name recipients, so a sender header
|
|
940
|
+
// this connection cannot read never blocks a reply addressed explicitly.
|
|
941
|
+
recipients: () => addressEntries(`The replied-to message's ${replyTo ? "Reply-To" : "From"} (pass to explicitly)`, oneLine(replyTo ?? header(payload, "From"))),
|
|
942
|
+
};
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
/** A header value read back from Gmail, unfolded before it is written again. */
|
|
946
|
+
function oneLine(value) {
|
|
947
|
+
return value?.replace(/[\r\n]+\s*/g, " ").trim();
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* A draft's id, its message id, and the thread Gmail placed it in. Gmail
|
|
951
|
+
* answered 2xx, so the draft was saved whatever its answer looks like: an id
|
|
952
|
+
* it sent that cannot be returned, or none at all, still reads as saved —
|
|
953
|
+
* with `draftId` absent, `omittedIds` or `note` saying why, and where to find
|
|
954
|
+
* it — never as a failure that invites saving it again.
|
|
955
|
+
*/
|
|
956
|
+
function projectSavedDraft(value) {
|
|
957
|
+
const draft = asRecord(value);
|
|
958
|
+
const message = asRecord(draft["message"]);
|
|
959
|
+
const saved = ids({ draftId: [draft["id"]], messageId: [message["id"]], threadId: [message["threadId"]] });
|
|
960
|
+
return compact({
|
|
961
|
+
...saved,
|
|
962
|
+
saved: true,
|
|
963
|
+
sent: false,
|
|
964
|
+
note: saved["draftId"] === undefined
|
|
965
|
+
? "Gmail saved the draft but returned no id this connection can carry. Find it with list_drafts before changing it; do not create it again."
|
|
966
|
+
: undefined,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
function addressList(value) {
|
|
970
|
+
return Array.isArray(value) ? value.map(String) : undefined;
|
|
971
|
+
}
|
|
972
|
+
/** The MIME types update_draft can rebuild: text, in any multipart shape. */
|
|
973
|
+
const REBUILDABLE = new Set(["text/plain", "text/html", "multipart/alternative", "multipart/mixed"]);
|
|
974
|
+
/**
|
|
975
|
+
* What in an existing draft update_draft could not carry over, by name.
|
|
976
|
+
* Gmail's update replaces the whole message, so anything the rebuilt message
|
|
977
|
+
* would not contain — an attachment, an inline image, a forwarded message, a
|
|
978
|
+
* calendar invitation — would be deleted for good. Such a draft is refused
|
|
979
|
+
* instead.
|
|
980
|
+
*/
|
|
981
|
+
function unrebuildable(payload) {
|
|
982
|
+
const found = [];
|
|
983
|
+
const visit = (part, depth) => {
|
|
984
|
+
const mime = mimeOf(part);
|
|
985
|
+
// A disposition the request's redaction rewrote can no longer prove the part is not carried,
|
|
986
|
+
// nor can a header whose name it rewrote: that header may be the Content-Disposition or Content-ID.
|
|
987
|
+
const disposition = header(part, "Content-Disposition");
|
|
988
|
+
if (isAttachment(part) ||
|
|
989
|
+
header(part, "Content-ID") ||
|
|
990
|
+
(mime && !REBUILDABLE.has(mime)) ||
|
|
991
|
+
disposition?.includes("[redacted]") ||
|
|
992
|
+
asArray(part["headers"]).some((entry) => text(entry?.name)?.includes("[redacted]"))) {
|
|
993
|
+
found.push(text(part["filename"]) ?? (mime || "an unnamed part"));
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const children = asArray(part["parts"]);
|
|
997
|
+
// Past the depth this inspects, what lies below is unknown, and unknown
|
|
998
|
+
// content is content the update could delete: refuse rather than assume.
|
|
999
|
+
if (depth >= MAX_PART_DEPTH && children.length > 0) {
|
|
1000
|
+
found.push(`parts nested more than ${MAX_PART_DEPTH} levels deep`);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
for (const child of children)
|
|
1004
|
+
visit(asRecord(child), depth + 1);
|
|
1005
|
+
};
|
|
1006
|
+
visit(payload, 0);
|
|
1007
|
+
return found;
|
|
1008
|
+
}
|
|
1009
|
+
// --- Tools ------------------------------------------------------------------------
|
|
1010
|
+
/** Each request a read sends names a step, so a deployment serving it as a write sends each once. */
|
|
1011
|
+
const indexedSteps = (prefix, count) => Object.fromEntries(Array.from({ length: count }, (_, index) => [`${prefix}${index}`, "once"]));
|
|
1012
|
+
/** Gmail splits a conversation into a new thread past 100 messages. */
|
|
1013
|
+
const MAX_THREAD_MESSAGES = 100;
|
|
1014
|
+
function declarations(client) {
|
|
1015
|
+
const readOnly = { readOnlyHint: true };
|
|
1016
|
+
const bodyChars = (args, fallback) => typeof args["maxBodyChars"] === "number" ? args["maxBodyChars"] : fallback;
|
|
1017
|
+
// Gmail's own page token, carried inside a cursor bound to this call. It is redacted first:
|
|
1018
|
+
// an agent decodes the cursor, and no later pass reads through its encoding.
|
|
1019
|
+
const gmailPage = (ctx, listing, tool, call) => {
|
|
1020
|
+
const rawToken = listing["nextPageToken"];
|
|
1021
|
+
if (rawToken === undefined || rawToken === "")
|
|
1022
|
+
return { hasMore: false, nextCursor: null };
|
|
1023
|
+
if (typeof rawToken !== "string" ||
|
|
1024
|
+
rawToken.includes("[redacted]") ||
|
|
1025
|
+
/^[a-z][a-z0-9+.-]*:/i.test(rawToken) ||
|
|
1026
|
+
[...rawToken].some((character) => character.charCodeAt(0) <= 32 || character.charCodeAt(0) === 127))
|
|
1027
|
+
throw new ConnectorCallError("connector_call_failed", "Gmail returned invalid paging metadata.", {
|
|
1028
|
+
retryable: false,
|
|
1029
|
+
});
|
|
1030
|
+
const token = rawToken;
|
|
1031
|
+
const cursor = encodeCursor(ctx, { v: 1, t: tool, c: call, p: redactSentSecrets(ctx, token) });
|
|
1032
|
+
if (cursor.length > MAX_CURSOR_LENGTH) {
|
|
1033
|
+
throw new ConnectorCallError("connector_call_failed", "Gmail answered with a page token too long to carry in a cursor; narrow the query to fit one page.", { retryable: false });
|
|
1034
|
+
}
|
|
1035
|
+
return { hasMore: true, nextCursor: cursor };
|
|
1036
|
+
};
|
|
1037
|
+
const compose = (args, ctx, kept) => Effect.gen(function* () {
|
|
1038
|
+
const reply = args["replyToMessageId"] ? yield* replyThreading(client, String(args["replyToMessageId"])) : kept;
|
|
1039
|
+
// What the caller states wins; then what the draft already had; then
|
|
1040
|
+
// what a reply defaults to. A new draft has nothing kept.
|
|
1041
|
+
const raw = buildRawMessage({
|
|
1042
|
+
from: kept.from ?? [],
|
|
1043
|
+
to: addressList(args["to"]) ?? (kept.to?.length ? kept.to : reply.recipients?.()) ?? [],
|
|
1044
|
+
cc: addressList(args["cc"]) ?? kept.cc ?? [],
|
|
1045
|
+
bcc: addressList(args["bcc"]) ?? kept.bcc ?? [],
|
|
1046
|
+
replyTo: kept.replyTo ?? [],
|
|
1047
|
+
subject: args["subject"] ?? kept.subject ?? reply.subject,
|
|
1048
|
+
body: String(args["body"] ?? ""),
|
|
1049
|
+
htmlBody: typeof args["htmlBody"] === "string" ? args["htmlBody"] : undefined,
|
|
1050
|
+
inReplyTo: reply.inReplyTo,
|
|
1051
|
+
references: reply.references,
|
|
1052
|
+
});
|
|
1053
|
+
return { message: compact({ raw, threadId: reply.threadId }) };
|
|
1054
|
+
});
|
|
1055
|
+
return [
|
|
1056
|
+
{
|
|
1057
|
+
name: "search_threads",
|
|
1058
|
+
writeSteps: { list: "once", ...indexedSteps("row", MAX_PAGE_SIZE) },
|
|
1059
|
+
description: "Search Gmail threads with conversation ids, newest first, using Gmail query syntax; returns subject, senders and snippet. Read each thread with get_thread for message ids and bodies.",
|
|
1060
|
+
annotations: readOnly,
|
|
1061
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1062
|
+
const limit = args["limit"] ?? DEFAULT_PAGE_SIZE;
|
|
1063
|
+
const call = yield* Effect.promise(() => callKey("search_threads", {
|
|
1064
|
+
query: args["query"] ?? null,
|
|
1065
|
+
includeSpamTrash: args["includeSpamTrash"] === true,
|
|
1066
|
+
limit,
|
|
1067
|
+
}));
|
|
1068
|
+
const claims = openCursor(args["cursor"], "search_threads", call);
|
|
1069
|
+
const listing = asRecord(yield* client.json({
|
|
1070
|
+
method: "GET",
|
|
1071
|
+
path: "/threads",
|
|
1072
|
+
template: "/threads",
|
|
1073
|
+
query: {
|
|
1074
|
+
q: args["query"],
|
|
1075
|
+
includeSpamTrash: args["includeSpamTrash"],
|
|
1076
|
+
maxResults: limit,
|
|
1077
|
+
pageToken: claims?.p,
|
|
1078
|
+
},
|
|
1079
|
+
}, { step: "list" }));
|
|
1080
|
+
const rows = asArray(listing["threads"]).map(asRecord);
|
|
1081
|
+
const threads = yield* mapLimited(rows.map((row, index) => ({ row, index })), SUMMARY_CONCURRENCY, ({ row, index }) => unlessGone(() => Effect.gen(function* () {
|
|
1082
|
+
return projectThreadSummary(ctx, yield* client.json({
|
|
1083
|
+
method: "GET",
|
|
1084
|
+
path: `/threads/${encodeURIComponent(String(row["id"]))}`,
|
|
1085
|
+
template: "/threads/{id}",
|
|
1086
|
+
query: { format: "metadata", metadataHeaders: ["Subject", "From", "Date"] },
|
|
1087
|
+
}, { step: `row${index}` }), row["snippet"]);
|
|
1088
|
+
})));
|
|
1089
|
+
return compact({
|
|
1090
|
+
threads: threads.filter((thread) => thread !== undefined),
|
|
1091
|
+
page: gmailPage(ctx, listing, "search_threads", call),
|
|
1092
|
+
});
|
|
1093
|
+
}),
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
name: "get_thread",
|
|
1097
|
+
writeSteps: { thread: "once", ...indexedSteps("body", MAX_THREAD_MESSAGES) },
|
|
1098
|
+
description: "Read a Gmail thread's messages and follow page.nextCursor until page.hasMore is false. Returns headers, capped plain-text bodies and attachment metadata; use get_attachment for attachment content.",
|
|
1099
|
+
annotations: readOnly,
|
|
1100
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1101
|
+
const thread = asRecord(yield* client.json({
|
|
1102
|
+
method: "GET",
|
|
1103
|
+
path: `/threads/${encodeURIComponent(args["threadId"])}`,
|
|
1104
|
+
template: "/threads/{id}",
|
|
1105
|
+
query: { format: "full" },
|
|
1106
|
+
}, { step: "thread" }));
|
|
1107
|
+
const max = bodyChars(args, DEFAULT_THREAD_BODY_CHARS);
|
|
1108
|
+
const call = yield* Effect.promise(() => callKey("get_thread", { threadId: args["threadId"], maxBodyChars: max }));
|
|
1109
|
+
const claims = openCursor(args["cursor"], "get_thread", call);
|
|
1110
|
+
const all = asArray(thread["messages"]);
|
|
1111
|
+
const ids = all.map((message) => JSON.stringify(message));
|
|
1112
|
+
const start = yield* Effect.promise(() => resumeAt(ids, claims, "thread"));
|
|
1113
|
+
// The whole result is budgeted: the wrapper (with the longest cursor
|
|
1114
|
+
// this connection issues) first, then messages and their separators
|
|
1115
|
+
// until the next would not fit. A message alone is projected to fit
|
|
1116
|
+
// beside the wrapper, so a page holds at least one and paging ends.
|
|
1117
|
+
const threadId = idOf(thread["id"]) ?? args["threadId"];
|
|
1118
|
+
let bytes = jsonBytes({ id: threadId, messageCount: all.length, messages: [], page: RESERVED_PAGE });
|
|
1119
|
+
const room = RESULT_BUDGET_BYTES - bytes;
|
|
1120
|
+
const messages = [];
|
|
1121
|
+
let next = start;
|
|
1122
|
+
while (next < all.length) {
|
|
1123
|
+
const projected = yield* projectMessage(client, ctx, all[next], max, room, undefined, `body${next}`);
|
|
1124
|
+
const size = jsonBytes(projected) + (messages.length > 0 ? 1 : 0);
|
|
1125
|
+
if (messages.length > 0 && bytes + size > RESULT_BUDGET_BYTES)
|
|
1126
|
+
break;
|
|
1127
|
+
messages.push(projected);
|
|
1128
|
+
bytes += size;
|
|
1129
|
+
next += 1;
|
|
1130
|
+
}
|
|
1131
|
+
const hasMore = next < all.length;
|
|
1132
|
+
return {
|
|
1133
|
+
id: threadId,
|
|
1134
|
+
messageCount: all.length,
|
|
1135
|
+
messages,
|
|
1136
|
+
page: {
|
|
1137
|
+
hasMore,
|
|
1138
|
+
nextCursor: hasMore ? yield* Effect.promise(() => cursorAfter(ctx, ids, next, "get_thread", call)) : null,
|
|
1139
|
+
},
|
|
1140
|
+
};
|
|
1141
|
+
}),
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
name: "get_message",
|
|
1145
|
+
writeSteps: { message: "once", body: "once" },
|
|
1146
|
+
description: "Get one Gmail message with headers, its plain-text body (HTML converted only when no text part exists), and attachment metadata. Read attachments with get_attachment using partId.",
|
|
1147
|
+
annotations: readOnly,
|
|
1148
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1149
|
+
const message = yield* client.json({
|
|
1150
|
+
method: "GET",
|
|
1151
|
+
path: `/messages/${encodeURIComponent(args["messageId"])}`,
|
|
1152
|
+
template: "/messages/{id}",
|
|
1153
|
+
query: { format: "full" },
|
|
1154
|
+
}, { step: "message" });
|
|
1155
|
+
return yield* projectMessage(client, ctx, message, bodyChars(args, DEFAULT_MESSAGE_BODY_CHARS));
|
|
1156
|
+
}),
|
|
1157
|
+
},
|
|
1158
|
+
{
|
|
1159
|
+
name: "get_attachment",
|
|
1160
|
+
writeSteps: { message: "once", attachment: "once" },
|
|
1161
|
+
description: "Read a Gmail attachment by immutable partId: text with its charset, or small binaries as standard base64 for Drive create_file. Refuses oversized downloads; text is capped by maxChars and the whole result by maxBytes.",
|
|
1162
|
+
annotations: readOnly,
|
|
1163
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1164
|
+
return yield* readAttachment(client, ctx, args);
|
|
1165
|
+
}),
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
name: "list_labels",
|
|
1169
|
+
description: "List all Gmail labels in one bounded reply with the ids messages carry; truncated reports omitted labels. Uses one mailbox read and no continuation. Search by name with label: in search_threads.",
|
|
1170
|
+
annotations: readOnly,
|
|
1171
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1172
|
+
const listing = asRecord(yield* client.json({ method: "GET", path: "/labels", template: "/labels" }));
|
|
1173
|
+
const all = asArray(listing["labels"]);
|
|
1174
|
+
// Gmail's labels endpoint is unpaged. Return that list once rather
|
|
1175
|
+
// than re-downloading it to serve artificial ten-row pages. The
|
|
1176
|
+
// ordinary result bound still applies, with explicit omissions.
|
|
1177
|
+
const labels = [];
|
|
1178
|
+
// A label whose id is too long to use is left out and counted, never
|
|
1179
|
+
// returned with a cut id that would name nothing.
|
|
1180
|
+
let omitted = 0;
|
|
1181
|
+
let bytes = jsonBytes({ labels: [], labelsOmitted: all.length, page: { hasMore: false } });
|
|
1182
|
+
let next = 0;
|
|
1183
|
+
while (next < all.length) {
|
|
1184
|
+
const label = asRecord(all[next]);
|
|
1185
|
+
const id = idOf(label["id"], LABEL_ID_BYTES);
|
|
1186
|
+
if (id === undefined) {
|
|
1187
|
+
omitted += 1;
|
|
1188
|
+
next += 1;
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const truncated = [];
|
|
1192
|
+
const row = compact({
|
|
1193
|
+
truncated,
|
|
1194
|
+
id,
|
|
1195
|
+
name: clipper(ctx, truncated)(text(label["name"]), FIELD_BYTES, "name"),
|
|
1196
|
+
type: label["type"] === "system" ? "system" : label["type"] === "user" ? "user" : undefined,
|
|
1197
|
+
});
|
|
1198
|
+
const size = jsonBytes(row) + (labels.length > 0 ? 1 : 0);
|
|
1199
|
+
if (bytes + size > RESULT_BUDGET_BYTES) {
|
|
1200
|
+
omitted += all.length - next;
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
labels.push(row);
|
|
1204
|
+
bytes += size;
|
|
1205
|
+
next += 1;
|
|
1206
|
+
}
|
|
1207
|
+
return compact({
|
|
1208
|
+
labels,
|
|
1209
|
+
labelsOmitted: omitted > 0 ? omitted : undefined,
|
|
1210
|
+
page: { hasMore: false },
|
|
1211
|
+
});
|
|
1212
|
+
}),
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
name: "list_drafts",
|
|
1216
|
+
writeSteps: { list: "once", ...indexedSteps("row", MAX_PAGE_SIZE) },
|
|
1217
|
+
description: "List the user's Gmail drafts, newest first, with subject, recipients, and snippet. Use get_draft for a body.",
|
|
1218
|
+
annotations: readOnly,
|
|
1219
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1220
|
+
const limit = args["limit"] ?? DEFAULT_PAGE_SIZE;
|
|
1221
|
+
const call = yield* Effect.promise(() => callKey("list_drafts", { query: args["query"] ?? null, limit }));
|
|
1222
|
+
const claims = openCursor(args["cursor"], "list_drafts", call);
|
|
1223
|
+
const listing = asRecord(yield* client.json({
|
|
1224
|
+
method: "GET",
|
|
1225
|
+
path: "/drafts",
|
|
1226
|
+
template: "/drafts",
|
|
1227
|
+
query: {
|
|
1228
|
+
q: args["query"],
|
|
1229
|
+
maxResults: limit,
|
|
1230
|
+
pageToken: claims?.p,
|
|
1231
|
+
},
|
|
1232
|
+
}, { step: "list" }));
|
|
1233
|
+
const rows = asArray(listing["drafts"]).map((row, index) => ({ row: asRecord(row), index }));
|
|
1234
|
+
const drafts = yield* mapLimited(rows, SUMMARY_CONCURRENCY, ({ row, index }) => unlessGone(() => Effect.gen(function* () {
|
|
1235
|
+
return projectDraftSummary(ctx, yield* client.json({
|
|
1236
|
+
method: "GET",
|
|
1237
|
+
path: `/drafts/${encodeURIComponent(String(row["id"]))}`,
|
|
1238
|
+
template: "/drafts/{id}",
|
|
1239
|
+
query: { format: "metadata" },
|
|
1240
|
+
}, { step: `row${index}` }));
|
|
1241
|
+
})));
|
|
1242
|
+
return {
|
|
1243
|
+
drafts: drafts.filter((draft) => draft !== undefined),
|
|
1244
|
+
page: gmailPage(ctx, listing, "list_drafts", call),
|
|
1245
|
+
};
|
|
1246
|
+
}),
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
name: "get_draft",
|
|
1250
|
+
writeSteps: { draft: "once", body: "once" },
|
|
1251
|
+
description: "Get one Gmail draft's headers and plain-text body by draft id. Reading it never sends it.",
|
|
1252
|
+
annotations: readOnly,
|
|
1253
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1254
|
+
const draft = asRecord(yield* client.json({
|
|
1255
|
+
method: "GET",
|
|
1256
|
+
path: `/drafts/${encodeURIComponent(args["draftId"])}`,
|
|
1257
|
+
template: "/drafts/{id}",
|
|
1258
|
+
query: { format: "full" },
|
|
1259
|
+
}, { step: "draft" }));
|
|
1260
|
+
// The draft was read by the id the caller passed, so that is its id
|
|
1261
|
+
// whatever Gmail echoed back.
|
|
1262
|
+
const draftId = idOf(draft["id"]) ?? args["draftId"];
|
|
1263
|
+
// A draft's message id changes on every update; the draft id does not.
|
|
1264
|
+
// The body is fitted against this finished shape, wrapper and all.
|
|
1265
|
+
return yield* projectMessage(client, ctx, draft["message"], bodyChars(args, DEFAULT_MESSAGE_BODY_CHARS), RESULT_BUDGET_BYTES,
|
|
1266
|
+
// Omissions name the public field: here the message's `id` is
|
|
1267
|
+
// returned as `messageId`.
|
|
1268
|
+
({ id, omittedIds, ...message }) => compact({
|
|
1269
|
+
draftId,
|
|
1270
|
+
messageId: id,
|
|
1271
|
+
...message,
|
|
1272
|
+
omittedIds: Array.isArray(omittedIds)
|
|
1273
|
+
? omittedIds.map((name) => (name === "id" ? "messageId" : name))
|
|
1274
|
+
: undefined,
|
|
1275
|
+
}));
|
|
1276
|
+
}),
|
|
1277
|
+
},
|
|
1278
|
+
{
|
|
1279
|
+
name: "create_draft",
|
|
1280
|
+
writeSteps: { reply: "once", write: "once" },
|
|
1281
|
+
description: "Create a Gmail draft, optionally as a reply in an existing thread. It is saved, never sent: the user reviews and sends it in Gmail.",
|
|
1282
|
+
// Additive: a new draft changes nothing that existed. Not read-only, so
|
|
1283
|
+
// program calls require a writable endpoint; other writes use
|
|
1284
|
+
// call_destructive_tool. The host controls approval.
|
|
1285
|
+
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
1286
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1287
|
+
const draft = yield* client.json({
|
|
1288
|
+
method: "POST",
|
|
1289
|
+
path: "/drafts",
|
|
1290
|
+
template: "/drafts",
|
|
1291
|
+
body: yield* compose(args, ctx, { threadId: undefined, inReplyTo: undefined, references: undefined }),
|
|
1292
|
+
});
|
|
1293
|
+
return projectSavedDraft(draft);
|
|
1294
|
+
}),
|
|
1295
|
+
},
|
|
1296
|
+
{
|
|
1297
|
+
name: "update_draft",
|
|
1298
|
+
writeSteps: { existing: "once", reply: "once", write: "once" },
|
|
1299
|
+
description: "Replace a Gmail draft's body, keeping its sender, recipients, subject, Reply-To, and thread unless restated. Refuses a draft with attachments. Never sends it.",
|
|
1300
|
+
// Destructive: Gmail replaces the draft's whole message, so the body it
|
|
1301
|
+
// held — possibly typed by the user — is gone. Still a draft, never sent.
|
|
1302
|
+
annotations: { readOnlyHint: false, destructiveHint: true },
|
|
1303
|
+
run: (args, ctx) => Effect.gen(function* () {
|
|
1304
|
+
const draftPath = `/drafts/${encodeURIComponent(args["draftId"])}`;
|
|
1305
|
+
// Read the whole draft before replacing it: Gmail's update takes a
|
|
1306
|
+
// whole message, so whatever the rebuilt one leaves out is deleted.
|
|
1307
|
+
// Kept unless restated: From, To, Cc, Bcc, Reply-To, Subject, the
|
|
1308
|
+
// thread, In-Reply-To, and References. Any other header is not.
|
|
1309
|
+
const existing = asRecord(yield* client.json({ method: "GET", path: draftPath, template: "/drafts/{id}", query: { format: "full" } }, { effect: "read", step: "existing" }));
|
|
1310
|
+
const message = asRecord(existing["message"]);
|
|
1311
|
+
const payload = asRecord(message["payload"]);
|
|
1312
|
+
const lost = unrebuildable(payload);
|
|
1313
|
+
if (lost.length > 0) {
|
|
1314
|
+
throw new ConnectorCallError("invalid_args", `This draft carries content update_draft cannot rebuild (${lost.slice(0, 5).join(", ")}${lost.length > 5 ? ", …" : ""}), and Gmail's update replaces the whole message, so it would be deleted. Nothing was changed. Edit this draft in Gmail, or write a new one with create_draft.`);
|
|
1315
|
+
}
|
|
1316
|
+
const keep = (name, argument) => args[argument] === undefined
|
|
1317
|
+
? addressEntries(`The draft's ${name} (pass ${argument} explicitly)`, oneLine(header(payload, name)))
|
|
1318
|
+
: undefined;
|
|
1319
|
+
const references = threadingIds(header(payload, "References"), true);
|
|
1320
|
+
const inReplyTo = threadingIds(header(payload, "In-Reply-To"), true);
|
|
1321
|
+
const kept = {
|
|
1322
|
+
threadId: text(message["threadId"]),
|
|
1323
|
+
inReplyTo: inReplyTo.length > 0 ? inReplyTo.join("\r\n ") : undefined,
|
|
1324
|
+
references: references.length > 0 ? references.join("\r\n ") : undefined,
|
|
1325
|
+
subject: oneLine(header(payload, "Subject")),
|
|
1326
|
+
from: addressEntries("The draft's From", oneLine(header(payload, "From"))),
|
|
1327
|
+
replyTo: addressEntries("The draft's Reply-To", oneLine(header(payload, "Reply-To"))),
|
|
1328
|
+
...defined({ to: keep("To", "to"), cc: keep("Cc", "cc"), bcc: keep("Bcc", "bcc") }),
|
|
1329
|
+
};
|
|
1330
|
+
const draft = yield* client.json({
|
|
1331
|
+
method: "PUT",
|
|
1332
|
+
path: draftPath,
|
|
1333
|
+
template: "/drafts/{id}",
|
|
1334
|
+
body: yield* compose(args, ctx, kept),
|
|
1335
|
+
});
|
|
1336
|
+
return projectSavedDraft(draft);
|
|
1337
|
+
}),
|
|
1338
|
+
},
|
|
1339
|
+
];
|
|
1340
|
+
}
|
|
1341
|
+
/** Internal size provenance becomes one top-level standard report, before fields narrow it. */
|
|
1342
|
+
function standardResult(value, tool) {
|
|
1343
|
+
const cuts = new Set();
|
|
1344
|
+
const visit = (value, path) => {
|
|
1345
|
+
if (Array.isArray(value))
|
|
1346
|
+
return value.map((item) => visit(item, `${path}[]`));
|
|
1347
|
+
if (value === null || typeof value !== "object")
|
|
1348
|
+
return value;
|
|
1349
|
+
const out = {};
|
|
1350
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1351
|
+
const target = (name) => (path ? `${path}.${name}` : name);
|
|
1352
|
+
if (key === "truncated") {
|
|
1353
|
+
for (const field of asArray(child))
|
|
1354
|
+
cuts.add(target(String(field)));
|
|
1355
|
+
}
|
|
1356
|
+
else if (key === "bodyTruncated" || key === "contentTruncated") {
|
|
1357
|
+
if (child === true)
|
|
1358
|
+
cuts.add(target(key === "bodyTruncated" ? "body" : "content"));
|
|
1359
|
+
}
|
|
1360
|
+
else if (key === "omittedIds") {
|
|
1361
|
+
for (const name of asArray(child))
|
|
1362
|
+
cuts.add(target(String(name)));
|
|
1363
|
+
}
|
|
1364
|
+
else if (["labelIdsOmitted", "attachmentsOmitted", "labelsOmitted"].includes(key)) {
|
|
1365
|
+
if (Number(child) > 0)
|
|
1366
|
+
cuts.add(target(key === "labelsOmitted" && tool === "list_labels" ? "items[]" : key.replace("Omitted", "") + "[]"));
|
|
1367
|
+
}
|
|
1368
|
+
else if (key === "nextCursor" && child === null) {
|
|
1369
|
+
// A terminal page has no cursor.
|
|
1370
|
+
}
|
|
1371
|
+
else
|
|
1372
|
+
out[key] = visit(child, target(key));
|
|
1373
|
+
}
|
|
1374
|
+
return out;
|
|
1375
|
+
};
|
|
1376
|
+
const raw = asRecord(value);
|
|
1377
|
+
const key = tool === "search_threads"
|
|
1378
|
+
? "threads"
|
|
1379
|
+
: tool === "list_drafts"
|
|
1380
|
+
? "drafts"
|
|
1381
|
+
: tool === "list_labels"
|
|
1382
|
+
? "labels"
|
|
1383
|
+
: undefined;
|
|
1384
|
+
const shaped = key ? { ...raw, items: raw[key] } : raw;
|
|
1385
|
+
if (key)
|
|
1386
|
+
delete shaped[key];
|
|
1387
|
+
const result = asRecord(visit(shaped, ""));
|
|
1388
|
+
if (cuts.size)
|
|
1389
|
+
result["truncated"] = [...cuts];
|
|
1390
|
+
return result;
|
|
1391
|
+
}
|
|
1392
|
+
/** Gmail only reads mail and saves drafts. Sending has no dispatch path. */
|
|
1393
|
+
export function gmailTools({ connection }) {
|
|
1394
|
+
return declarations(googleClient(connection, GMAIL.provider, { standard: true, index })).map((declaration) => {
|
|
1395
|
+
const name = declaration.name;
|
|
1396
|
+
const isList = name === "search_threads" || name === "list_labels" || name === "list_drafts";
|
|
1397
|
+
const spec = {
|
|
1398
|
+
name,
|
|
1399
|
+
description: declaration.description,
|
|
1400
|
+
input: inputs[name],
|
|
1401
|
+
output: outputs[name],
|
|
1402
|
+
...(declaration.writeSteps ? { writeSteps: declaration.writeSteps } : {}),
|
|
1403
|
+
run: (supplied) => Effect.gen(function* () {
|
|
1404
|
+
const active = yield* Effect.serviceOption(Operation);
|
|
1405
|
+
if (Option.isNone(active) || !active.value)
|
|
1406
|
+
return yield* Effect.die(new Error("Gmail needs its call context."));
|
|
1407
|
+
const ctx = active.value;
|
|
1408
|
+
const { fields, ...args } = supplied;
|
|
1409
|
+
const problem = fieldsProblem(fields, new Set(Object.keys(isList ? rows[name].fields : outputs[name].fields)), isList ? "an item" : "the result");
|
|
1410
|
+
if (problem)
|
|
1411
|
+
return yield* Effect.fail(new Refusal({ error: problem }));
|
|
1412
|
+
if (args["cursor"] !== undefined)
|
|
1413
|
+
args["cursor"] = yield* boundCursor.decode(name, args, args["cursor"]);
|
|
1414
|
+
let result = standardResult(yield* declaration.run(args, ctx), name);
|
|
1415
|
+
if (result["page"]?.nextCursor)
|
|
1416
|
+
result["page"].nextCursor = yield* boundCursor.encode(name, args, result["page"].nextCursor, (json) => encodeForAgent(ctx, json, { url: true, claims: true }));
|
|
1417
|
+
if (isList)
|
|
1418
|
+
result["items"] = selectFields(result["items"], fields);
|
|
1419
|
+
else {
|
|
1420
|
+
const { truncated, page } = result;
|
|
1421
|
+
result = asRecord(selectFields(result, fields));
|
|
1422
|
+
if (page !== undefined)
|
|
1423
|
+
result["page"] = page;
|
|
1424
|
+
// Size provenance describes the full projection even when fields omit the cut value.
|
|
1425
|
+
if (truncated !== undefined)
|
|
1426
|
+
result["truncated"] = truncated;
|
|
1427
|
+
}
|
|
1428
|
+
return deliverable(name, result, name === "get_attachment" && typeof args["maxBytes"] === "number" ? args["maxBytes"] : RESULT_BUDGET_BYTES);
|
|
1429
|
+
}).pipe(Effect.catchDefect((defect) => defect instanceof ConnectorCallError ? Effect.fail(new Refusal({ error: defect })) : Effect.die(defect))),
|
|
1430
|
+
};
|
|
1431
|
+
return declaration.annotations.readOnlyHint === true
|
|
1432
|
+
? read(connection, spec)
|
|
1433
|
+
: write(connection, { ...spec, destructive: declaration.annotations.destructiveHint !== false });
|
|
1434
|
+
});
|
|
1435
|
+
}
|