@zackbart/connecta 0.28.1 → 0.30.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.
Files changed (332) hide show
  1. package/CHANGELOG.md +855 -5
  2. package/README.md +125 -116
  3. package/bin/connecta.mjs +121 -139
  4. package/bin/meta-tool-names.d.mts +2 -0
  5. package/bin/meta-tool-names.mjs +9 -0
  6. package/bin/version.mjs +3 -0
  7. package/dist/access-tokens.js +53 -63
  8. package/dist/activity-request.js +8 -0
  9. package/dist/activity.d.ts +76 -42
  10. package/dist/activity.js +110 -42
  11. package/dist/argument-redaction.js +230 -0
  12. package/dist/auth/clerk-sdk/client.d.ts +65 -0
  13. package/dist/auth/clerk-sdk/client.js +5 -0
  14. package/dist/auth/clerk-sdk/generated.js +1845 -0
  15. package/dist/auth/clerk-transport.js +19 -0
  16. package/dist/auth/clerk.d.ts +17 -7
  17. package/dist/auth/clerk.js +189 -107
  18. package/dist/auth/cloudflare-access.js +25 -26
  19. package/dist/auth/downstream-client-metadata.d.ts +29 -0
  20. package/dist/auth/downstream-client-metadata.js +133 -0
  21. package/dist/auth/downstream-oauth.js +1584 -1580
  22. package/dist/auth/downstream-revocation.js +106 -0
  23. package/dist/auth/oauth-token-response.js +231 -0
  24. package/dist/auth/oauth-v2-migration.js +116 -0
  25. package/dist/auth/static-oauth.js +93 -71
  26. package/dist/auth-elicitation.js +288 -0
  27. package/dist/authorization-handoff.js +111 -0
  28. package/dist/branding.d.ts +8 -16
  29. package/dist/branding.js +26 -59
  30. package/dist/byte-read-response.js +39 -0
  31. package/dist/call-admission.js +12 -16
  32. package/dist/catalog-cache.js +739 -0
  33. package/dist/catalog-drift.d.ts +50 -15
  34. package/dist/catalog-drift.js +365 -128
  35. package/dist/catalog-fingerprint.js +2 -4
  36. package/dist/catalog-freshness.js +13 -0
  37. package/dist/catalog-limits.js +2 -2
  38. package/dist/catalog-service.js +409 -225
  39. package/dist/catalog.js +43 -84
  40. package/dist/concurrency.js +3 -4
  41. package/dist/config-defaults.d.ts +39 -0
  42. package/dist/config-defaults.js +46 -0
  43. package/dist/config-schema.d.ts +181 -0
  44. package/dist/config-schema.js +435 -0
  45. package/dist/config-value-sources.js +102 -0
  46. package/dist/config.d.ts +633 -0
  47. package/dist/config.js +508 -0
  48. package/dist/connector-access.d.ts +1 -0
  49. package/dist/connector-access.js +13 -15
  50. package/dist/connector-caller.d.ts +8 -0
  51. package/dist/connector-caller.js +15 -6
  52. package/dist/connector-scope.js +1 -1
  53. package/dist/connectors/api-connector.d.ts +59 -7
  54. package/dist/connectors/api-connector.js +106 -19
  55. package/dist/connectors/api.js +4 -1
  56. package/dist/connectors/guarded-fetch.d.ts +144 -0
  57. package/dist/connectors/guarded-fetch.js +87 -38
  58. package/dist/connectors/negotiation-cache.js +46 -0
  59. package/dist/connectors/option-shapes.js +27 -0
  60. package/dist/connectors/remote-mcp.d.ts +30 -4
  61. package/dist/connectors/remote-mcp.js +1774 -320
  62. package/dist/connectors/resource-uri.js +335 -0
  63. package/dist/credential-contract.d.ts +10 -0
  64. package/dist/credential-rules.js +1 -3
  65. package/dist/credential-url.js +108 -0
  66. package/dist/credentials.d.ts +6 -1
  67. package/dist/credentials.js +27 -18
  68. package/dist/d1.d.ts +130 -0
  69. package/dist/d1.js +290 -0
  70. package/dist/describe-config.d.ts +165 -0
  71. package/dist/describe-config.js +315 -0
  72. package/dist/described.js +106 -0
  73. package/dist/downstream-input-context.js +128 -0
  74. package/dist/downstream-input.js +325 -0
  75. package/dist/errors.d.ts +132 -31
  76. package/dist/errors.js +175 -87
  77. package/dist/execute.js +798 -318
  78. package/dist/executor-admission.d.ts +1 -0
  79. package/dist/executor-admission.js +11 -15
  80. package/dist/executor-contract.d.ts +20 -0
  81. package/dist/executor-contract.js +48 -0
  82. package/dist/executor-result.js +30 -13
  83. package/dist/executors/quickjs-child.js +15 -9
  84. package/dist/executors/quickjs-protocol.js +5 -6
  85. package/dist/executors/quickjs-runtime.js +152 -59
  86. package/dist/executors/quickjs.js +49 -39
  87. package/dist/favicon.js +1 -2
  88. package/dist/fix-prompt.js +1 -3
  89. package/dist/guest-runtime.js +238 -0
  90. package/dist/guest-types.d.ts +218 -0
  91. package/dist/guest-types.js +1 -0
  92. package/dist/html-security.js +24 -0
  93. package/dist/identity.js +1 -3
  94. package/dist/inbound-credential.js +52 -0
  95. package/dist/index.d.ts +24 -256
  96. package/dist/index.js +65 -478
  97. package/dist/invocation-auth.js +69 -0
  98. package/dist/invocation.js +262 -79
  99. package/dist/json-schema.d.ts +1 -1
  100. package/dist/mcp-client-context.js +35 -0
  101. package/dist/mcp-result.js +11 -7
  102. package/dist/meta-output.js +61 -0
  103. package/dist/meta-tool-names.d.ts +2 -0
  104. package/dist/meta-tool-names.js +3 -0
  105. package/dist/meta-tools.js +419 -272
  106. package/dist/module-contracts.d.ts +8 -23
  107. package/dist/node.d.ts +0 -2
  108. package/dist/node.js +2 -2
  109. package/dist/oauth-callback-outcome.js +1 -3
  110. package/dist/oauth-handoff.js +194 -0
  111. package/dist/oauth-sealing.js +20 -0
  112. package/dist/operator-record.d.ts +137 -0
  113. package/dist/operator-record.js +420 -0
  114. package/dist/operator-ui/assets.js +31 -0
  115. package/dist/operator-ui/contract.d.ts +72 -0
  116. package/dist/operator-ui/contract.js +1 -0
  117. package/dist/operator-ui/fix-prompts.d.ts +18 -0
  118. package/dist/operator-ui/generated.js +5 -4
  119. package/dist/operator-ui/model.d.ts +12 -87
  120. package/dist/operator-ui/model.js +2 -9
  121. package/dist/operator-ui/setup-commands.js +1 -3
  122. package/dist/operator-ui/view.d.ts +323 -0
  123. package/dist/operator-ui/view.js +62 -189
  124. package/dist/page-styles.js +2 -3
  125. package/dist/program-result.js +5 -0
  126. package/dist/program-source.js +14 -2
  127. package/dist/{exempt-writes.js → program-writes.js} +60 -19
  128. package/dist/provider.d.ts +142 -0
  129. package/dist/provider.js +187 -0
  130. package/dist/providers/_shared/google/result-size.js +57 -0
  131. package/dist/providers/_shared/google/service-account.d.ts +58 -0
  132. package/dist/providers/_shared/google/service-account.js +500 -0
  133. package/dist/providers/_shared/google/workspace.d.ts +276 -0
  134. package/dist/providers/_shared/google/workspace.js +518 -0
  135. package/dist/providers/_shared/request-token.js +83 -0
  136. package/dist/providers/{basecamp.d.ts → basecamp/index.d.ts} +6 -28
  137. package/dist/providers/basecamp/index.js +997 -0
  138. package/dist/providers/basecamp/skill.generated.js +9 -0
  139. package/dist/providers/{breeze.d.ts → breeze/index.d.ts} +2 -2
  140. package/dist/providers/{breeze.js → breeze/index.js} +184 -140
  141. package/dist/providers/breeze/skill.generated.js +9 -0
  142. package/dist/providers/{ccb.d.ts → ccb/index.d.ts} +2 -2
  143. package/dist/providers/{ccb.js → ccb/index.js} +271 -116
  144. package/dist/providers/ccb/skill.generated.js +9 -0
  145. package/dist/providers/{cloudflare.d.ts → cloudflare/index.d.ts} +8 -7
  146. package/dist/providers/{cloudflare.js → cloudflare/index.js} +171 -738
  147. package/dist/providers/cloudflare/skill.generated.js +10 -0
  148. package/dist/providers/docs/index.d.ts +54 -0
  149. package/dist/providers/docs/index.js +1237 -0
  150. package/dist/providers/docs/skill.generated.js +9 -0
  151. package/dist/providers/drive/index.d.ts +53 -0
  152. package/dist/providers/drive/index.js +1523 -0
  153. package/dist/providers/drive/skill.generated.js +9 -0
  154. package/dist/providers/forms/index.d.ts +54 -0
  155. package/dist/providers/forms/index.js +1217 -0
  156. package/dist/providers/forms/skill.generated.js +11 -0
  157. package/dist/providers/github/auth.d.ts +32 -0
  158. package/dist/providers/github/auth.js +361 -0
  159. package/dist/providers/github/hosted.js +218 -0
  160. package/dist/providers/github/index.d.ts +13 -0
  161. package/dist/providers/github/index.js +140 -0
  162. package/dist/providers/github/key.js +100 -0
  163. package/dist/providers/github/rest.js +321 -0
  164. package/dist/providers/github/scope.d.ts +29 -0
  165. package/dist/providers/github/scope.js +124 -0
  166. package/dist/providers/github/skill.generated.js +8 -0
  167. package/dist/providers/gmail/index.d.ts +51 -0
  168. package/dist/providers/gmail/index.js +1436 -0
  169. package/dist/providers/gmail/skill.generated.js +9 -0
  170. package/dist/providers/infisical/index.d.ts +7 -0
  171. package/dist/providers/infisical/index.js +713 -0
  172. package/dist/providers/infisical/skill.generated.js +8 -0
  173. package/dist/providers/{linear.d.ts → linear/index.d.ts} +11 -14
  174. package/dist/providers/linear/index.js +352 -0
  175. package/dist/providers/linear/skill.generated.js +8 -0
  176. package/dist/providers/{mixpanel.d.ts → mixpanel/index.d.ts} +5 -10
  177. package/dist/providers/mixpanel/index.js +416 -0
  178. package/dist/providers/mixpanel/skill.generated.js +9 -0
  179. package/dist/providers/{notion.d.ts → notion/index.d.ts} +8 -8
  180. package/dist/providers/{notion.js → notion/index.js} +226 -239
  181. package/dist/providers/notion/skill.generated.js +10 -0
  182. package/dist/providers/{overflow.d.ts → overflow/index.d.ts} +2 -2
  183. package/dist/providers/{overflow.js → overflow/index.js} +74 -111
  184. package/dist/providers/overflow/skill.generated.js +8 -0
  185. package/dist/providers/{planning-center.d.ts → planning-center/index.d.ts} +2 -2
  186. package/dist/providers/{planning-center.js → planning-center/index.js} +264 -104
  187. package/dist/providers/planning-center/skill.generated.js +10 -0
  188. package/dist/providers/{revenuecat.d.ts → revenuecat/index.d.ts} +5 -10
  189. package/dist/providers/revenuecat/index.js +557 -0
  190. package/dist/providers/revenuecat/skill.generated.js +11 -0
  191. package/dist/providers/sheets/index.d.ts +54 -0
  192. package/dist/providers/sheets/index.js +1237 -0
  193. package/dist/providers/sheets/skill.generated.js +10 -0
  194. package/dist/providers/slides/index.d.ts +53 -0
  195. package/dist/providers/slides/index.js +2645 -0
  196. package/dist/providers/slides/skill.generated.js +9 -0
  197. package/dist/providers/{stripe.d.ts → stripe/index.d.ts} +5 -12
  198. package/dist/providers/stripe/index.js +266 -0
  199. package/dist/providers/stripe/skill.generated.js +11 -0
  200. package/dist/providers/{tithely.d.ts → tithely/index.d.ts} +2 -2
  201. package/dist/providers/{tithely.js → tithely/index.js} +60 -91
  202. package/dist/providers/tithely/skill.generated.js +8 -0
  203. package/dist/providers/{vercel.d.ts → vercel/index.d.ts} +8 -8
  204. package/dist/providers/vercel/index.js +1038 -0
  205. package/dist/providers/vercel/mcp-ownership.js +212 -0
  206. package/dist/providers/vercel/skill.generated.js +11 -0
  207. package/dist/registry.d.ts +110 -171
  208. package/dist/registry.js +588 -927
  209. package/dist/request-state.js +21 -0
  210. package/dist/resource-read.js +103 -0
  211. package/dist/result-shapes.js +17 -49
  212. package/dist/retired-toolkits.js +1 -1
  213. package/dist/routes/access-tokens.js +3 -6
  214. package/dist/routes/activity-context.js +21 -0
  215. package/dist/routes/activity-disclosure.js +40 -0
  216. package/dist/routes/activity.js +59 -21
  217. package/dist/routes/actor-labels.js +3 -50
  218. package/dist/routes/connect.js +221 -0
  219. package/dist/routes/credentials.js +15 -27
  220. package/dist/routes/mcp.js +365 -130
  221. package/dist/routes/oauth-client-metadata.js +15 -0
  222. package/dist/routes/oauth-management.js +45 -107
  223. package/dist/routes/oauth.js +110 -57
  224. package/dist/routes/operator.js +3 -3
  225. package/dist/routes/shared.d.ts +25 -64
  226. package/dist/routes/shared.js +160 -80
  227. package/dist/routes/skills.js +30 -0
  228. package/dist/routes/ui-config.js +225 -0
  229. package/dist/routes/ui-data.js +40 -35
  230. package/dist/routes/ui.js +66 -56
  231. package/dist/runtime/admission.js +4 -2
  232. package/dist/runtime/call-admission.js +3 -1
  233. package/dist/runtime/connector-scope.js +1 -4
  234. package/dist/runtime/run.js +10 -39
  235. package/dist/runtime/services.js +12 -117
  236. package/dist/runtime/shared-read.js +1 -3
  237. package/dist/runtime/storage.js +8 -1
  238. package/dist/sent-secrets.d.ts +93 -0
  239. package/dist/sent-secrets.js +1013 -0
  240. package/dist/server.js +28 -71
  241. package/dist/skills.js +416 -174
  242. package/dist/sqlite.d.ts +48 -0
  243. package/dist/sqlite.js +181 -0
  244. package/dist/storage/keys.js +443 -0
  245. package/dist/storage/memory.js +16 -4
  246. package/dist/storage/sql.d.ts +66 -0
  247. package/dist/storage/sql.js +543 -0
  248. package/dist/timeout.js +4 -2
  249. package/dist/tool-name.js +17 -0
  250. package/dist/tool-safety.d.ts +9 -35
  251. package/dist/tool-safety.js +20 -40
  252. package/dist/types.d.ts +336 -56
  253. package/dist/types.js +0 -1
  254. package/dist/ui.d.ts +7 -19
  255. package/dist/ui.js +76 -90
  256. package/dist/url-safety.js +3 -6
  257. package/dist/usage-guide.js +146 -0
  258. package/dist/validate.d.ts +7 -0
  259. package/dist/validate.js +309 -47
  260. package/dist/version.d.ts +1 -7
  261. package/dist/version.js +3 -7
  262. package/dist/worker.d.ts +11 -0
  263. package/dist/worker.js +435 -0
  264. package/documentation/README.md +63 -0
  265. package/documentation/architecture.md +696 -231
  266. package/documentation/auth.md +1069 -546
  267. package/documentation/code-mode.md +662 -356
  268. package/documentation/deploying.md +146 -0
  269. package/documentation/integrating.md +159 -0
  270. package/documentation/meta-tools.md +570 -216
  271. package/documentation/operating.md +135 -0
  272. package/documentation/operator-tests.md +97 -0
  273. package/documentation/operator-ui.md +132 -0
  274. package/documentation/provider-migration-0.29.md +65 -0
  275. package/examples/worker/AGENTS.md +34 -12
  276. package/examples/worker/README.md +293 -255
  277. package/examples/worker/kv-to-d1.wrangler.jsonc +26 -0
  278. package/examples/worker/scripts/copy-kv-to-d1.mjs +162 -0
  279. package/examples/worker/src/connecta.config.ts +151 -0
  280. package/examples/worker/src/index.ts +14 -197
  281. package/examples/worker/wrangler.jsonc +15 -35
  282. package/package.json +126 -54
  283. package/templates/node/.dockerignore +1 -2
  284. package/templates/node/.env.example +20 -21
  285. package/templates/node/AGENTS.md +21 -17
  286. package/templates/node/Dockerfile +3 -5
  287. package/templates/node/README.md +140 -58
  288. package/templates/node/docker-compose.yml +11 -12
  289. package/templates/node/package.json +4 -2
  290. package/templates/node/src/connecta.config.ts +119 -0
  291. package/templates/node/src/index.ts +9 -154
  292. package/templates/node/src/provision-token.ts +17 -0
  293. package/AGENTS.md +0 -170
  294. package/dist/artifacts/connector.d.ts +0 -58
  295. package/dist/artifacts/connector.js +0 -730
  296. package/dist/artifacts/document.js +0 -86
  297. package/dist/artifacts/guide.js +0 -140
  298. package/dist/artifacts/html-scan.js +0 -329
  299. package/dist/artifacts/json.js +0 -78
  300. package/dist/artifacts/kv-store.d.ts +0 -20
  301. package/dist/artifacts/kv-store.js +0 -140
  302. package/dist/artifacts/markdown.js +0 -478
  303. package/dist/artifacts/module.d.ts +0 -37
  304. package/dist/artifacts/module.js +0 -66
  305. package/dist/artifacts/operations.d.ts +0 -197
  306. package/dist/artifacts/operations.js +0 -1002
  307. package/dist/artifacts/refresh.d.ts +0 -55
  308. package/dist/artifacts/refresh.js +0 -236
  309. package/dist/artifacts/routes.js +0 -206
  310. package/dist/artifacts/types.d.ts +0 -214
  311. package/dist/artifacts/types.js +0 -22
  312. package/dist/artifacts/validate.d.ts +0 -62
  313. package/dist/artifacts/validate.js +0 -651
  314. package/dist/artifacts.d.ts +0 -6
  315. package/dist/artifacts.js +0 -9
  316. package/dist/auth/bearer.d.ts +0 -11
  317. package/dist/auth/bearer.js +0 -50
  318. package/dist/providers/basecamp.js +0 -445
  319. package/dist/providers/linear.js +0 -217
  320. package/dist/providers/mixpanel.js +0 -247
  321. package/dist/providers/revenuecat.js +0 -317
  322. package/dist/providers/stripe.js +0 -253
  323. package/dist/providers/vercel.js +0 -1253
  324. package/dist/storage/file.d.ts +0 -15
  325. package/dist/storage/file.js +0 -455
  326. package/ethos.md +0 -93
  327. package/examples/worker/src/cloudflare-kv.ts +0 -43
  328. package/examples/worker/src/d1-activity-row.ts +0 -155
  329. package/examples/worker/src/d1-activity.ts +0 -147
  330. package/examples/worker/src/d1-storage.ts +0 -138
  331. package/examples/worker/src/r2-artifact-blobs.ts +0 -29
  332. package/templates/node/src/file-activity.ts +0 -182
@@ -0,0 +1,1436 @@
1
+ import { skill } from "./skill.generated.js";
2
+ /**
3
+ * Gmail, draft-only, as the signed-in Workspace user: search and read mail,
4
+ * list labels, and create or update drafts — and nothing that sends, deletes,
5
+ * or relabels. Hand-written against the Gmail API v1 reference
6
+ * (https://developers.google.com/workspace/gmail/api/reference/rest).
7
+ *
8
+ * Whose mailbox. Access is a service account with domain-wide delegation
9
+ * (`src/providers/_shared/google/workspace.ts`): deployment config maps the admitted
10
+ * identity to a Workspace address, and each call mints, or reuses, a token as
11
+ * that user. There is no per-user consent step, no operator credential slot,
12
+ * and no argument that names a mailbox — the transport is confined beneath
13
+ * `users/me`, so the token's subject is the only mailbox any request reaches.
14
+ *
15
+ * Why draft-only. `gmail.compose` is the narrowest scope that can write a
16
+ * draft, and Google lets it send too; the tool surface is what keeps this
17
+ * connection from sending. There is no send tool and no raw hatch (H14), so
18
+ * nothing reaches `drafts.send` or `messages.send`. A deployment that wants an
19
+ * agent to send mail is choosing something this provider deliberately does
20
+ * not offer.
21
+ *
22
+ * Drift. Google publishes a credential-free Discovery document per API.
23
+ * `src/providers/gmail/drift.json` records the nine methods the tools
24
+ * call, and `npm run providers:check -- --provider gmail` reports a touched
25
+ * contract that moved or a method that stopped accepting the scopes below.
26
+ */
27
+ import { apiConnector as api, defined } from "../../connectors/api-connector.js";
28
+ import { ConnectorCallError } from "../../errors.js";
29
+ import { GOOGLE_WORKSPACE_OPTIONS, googleWorkspaceClient, workspaceConnection, } from "../_shared/google/workspace.js";
30
+ import { RESULT_BUDGET_BYTES, clampText, jsonBytes } from "../_shared/google/result-size.js";
31
+ import { asProviderFactory } from "../../provider.js";
32
+ /**
33
+ * Gmail's API root, confined to the delegated user's own mailbox: `me` is the
34
+ * token's subject, and no path can climb out of it.
35
+ */
36
+ export const GMAIL_API_BASE_URL = "https://gmail.googleapis.com/gmail/v1/users/me";
37
+ /**
38
+ * Exactly the scopes this connection requests, and exactly what the Admin
39
+ * console's domain-wide delegation entry must list (comma-separated there).
40
+ * `gmail.compose` technically permits sending; the tool surface does not.
41
+ */
42
+ export const GMAIL_SCOPES = [
43
+ "https://www.googleapis.com/auth/gmail.readonly",
44
+ "https://www.googleapis.com/auth/gmail.compose",
45
+ ];
46
+ /**
47
+ * Each summary row costs one metadata read, so a page stays small. Gmail's
48
+ * own list maximum is 500.
49
+ */
50
+ const MAX_PAGE_SIZE = 25;
51
+ const DEFAULT_PAGE_SIZE = 10;
52
+ /** Bodies are capped per message; a thread multiplies this. */
53
+ const DEFAULT_THREAD_BODY_CHARS = 3_000;
54
+ const DEFAULT_MESSAGE_BODY_CHARS = 12_000;
55
+ const MAX_BODY_CHARS = 100_000;
56
+ /** Summary reads in flight at once for one page. */
57
+ const SUMMARY_CONCURRENCY = 5;
58
+ /** A full thread without attachment bodies; Gmail's message cap is 25 MB. */
59
+ const GMAIL_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
60
+ const MAX_RECIPIENTS = 100;
61
+ function asRecord(value) {
62
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
63
+ }
64
+ function asArray(value) {
65
+ return Array.isArray(value) ? value : [];
66
+ }
67
+ function compact(value) {
68
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
69
+ }
70
+ function text(value) {
71
+ return typeof value === "string" && value !== "" ? value : undefined;
72
+ }
73
+ function header(payload, name) {
74
+ const wanted = name.toLowerCase();
75
+ for (const entry of asArray(payload["headers"])) {
76
+ if (entry?.name?.toLowerCase() === wanted)
77
+ return text(entry.value);
78
+ }
79
+ return undefined;
80
+ }
81
+ const ENTITIES = {
82
+ amp: "&",
83
+ lt: "<",
84
+ gt: ">",
85
+ quot: '"',
86
+ apos: "'",
87
+ nbsp: " ",
88
+ };
89
+ function decodeEntities(value) {
90
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (entity, name) => {
91
+ if (name[0] === "#") {
92
+ const code = name[1] === "x" || name[1] === "X" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
93
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity;
94
+ }
95
+ return ENTITIES[name.toLowerCase()] ?? entity;
96
+ });
97
+ }
98
+ /** Readable text from an HTML part that has no plain-text sibling. */
99
+ function htmlToText(html) {
100
+ return decodeEntities(html
101
+ .replace(/<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, "")
102
+ .replace(/<br\s*\/?>/gi, "\n")
103
+ .replace(/<li\b[^>]*>/gi, "\n- ")
104
+ .replace(/<\/(p|div|tr|li|h[1-6]|blockquote|table)\s*>/gi, "\n")
105
+ .replace(/<[^>]*>/g, ""))
106
+ .replace(/[ \t\u00a0]+\n/g, "\n")
107
+ .replace(/\n{3,}/g, "\n\n")
108
+ .trim();
109
+ }
110
+ function base64UrlBytes(data) {
111
+ const base64 = data.replace(/-/g, "+").replace(/_/g, "/");
112
+ const binary = atob(base64 + "=".repeat((4 - (base64.length % 4)) % 4));
113
+ const bytes = new Uint8Array(binary.length);
114
+ for (let index = 0; index < binary.length; index += 1) {
115
+ bytes[index] = binary.charCodeAt(index);
116
+ }
117
+ return bytes;
118
+ }
119
+ function charsetOf(part) {
120
+ const type = header(part, "Content-Type") ?? "";
121
+ return /charset="?([^";\s]+)"?/i.exec(type)?.[1] ?? "utf-8";
122
+ }
123
+ /** Bytes as text, in the part's declared charset where the runtime knows it. */
124
+ function decodeIn(part, data) {
125
+ let bytes;
126
+ try {
127
+ bytes = base64UrlBytes(data);
128
+ }
129
+ catch {
130
+ return "";
131
+ }
132
+ try {
133
+ return new TextDecoder(charsetOf(part)).decode(bytes);
134
+ }
135
+ catch {
136
+ return new TextDecoder().decode(bytes);
137
+ }
138
+ }
139
+ function mimeOf(part) {
140
+ return String(part["mimeType"] ?? "").toLowerCase();
141
+ }
142
+ /**
143
+ * Where the message's own content ends and something it carries begins: a
144
+ * named file, a part disposed as an attachment, or a whole forwarded message.
145
+ * Everything beneath such a part belongs to it — an attached email's text is
146
+ * not the message's body.
147
+ */
148
+ function isAttachment(part) {
149
+ const mime = mimeOf(part);
150
+ return (Boolean(text(part["filename"])) ||
151
+ /^\s*attachment\b/i.test(header(part, "Content-Disposition") ?? "") ||
152
+ mime === "message/rfc822" ||
153
+ // Any leaf that is not body text — an unnamed inline image, a calendar
154
+ // invitation — is carried content, listed rather than dropped. A text
155
+ // body Gmail stored apart (an attachmentId, no filename) stays a body.
156
+ (mime !== "" && !mime.startsWith("multipart/") && !BODY_TYPES.has(mime)));
157
+ }
158
+ /** The only parts that can be a message's own body. */
159
+ const BODY_TYPES = new Set(["text/plain", "text/html"]);
160
+ /** How deep a payload is walked; deeper than this is refused, not guessed. */
161
+ const MAX_PART_DEPTH = 20;
162
+ /**
163
+ * Walk a payload keeping ancestry: the message's own parts, in order, and the
164
+ * attachment roots, whose subtrees are never entered.
165
+ */
166
+ function walk(root) {
167
+ const own = [];
168
+ const attachments = [];
169
+ const visit = (part, depth) => {
170
+ if (isAttachment(part)) {
171
+ attachments.push(part);
172
+ return;
173
+ }
174
+ own.push(part);
175
+ if (depth >= MAX_PART_DEPTH)
176
+ return;
177
+ for (const child of asArray(part["parts"]))
178
+ visit(asRecord(child), depth + 1);
179
+ };
180
+ visit(root, 0);
181
+ return { own, attachments };
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
+ async function readBody(client, ctx, messageId, payload, max) {
192
+ const own = walk(payload).own;
193
+ const plain = own.find((part) => mimeOf(part) === "text/plain");
194
+ const chosen = plain ?? own.find((part) => mimeOf(part) === "text/html");
195
+ if (!chosen)
196
+ return { body: "", bodyTruncated: false, bodyFormat: "none" };
197
+ const format = 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(await client.json({
214
+ method: "GET",
215
+ path: `/messages/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
216
+ }, ctx));
217
+ data = text(fetched["data"]);
218
+ }
219
+ const decoded = data ? decodeIn(chosen, data) : "";
220
+ // Uncut: the caller caps it, by characters and by the result's bytes.
221
+ return { text: format === "html" ? htmlToText(decoded) : decoded, bodyFormat: format };
222
+ }
223
+ /**
224
+ * Gmail's ids are short opaque tokens — message and thread ids are sixteen
225
+ * hex digits, label ids `INBOX` or `Label_123`, attachment ids a few hundred
226
+ * characters. One far past that is not an id this connection can use. It is
227
+ * dropped, never cut — a cut id names nothing — and the drop is said out loud:
228
+ * an object lists the fields it omitted in `omittedIds`, and a list counts
229
+ * what it left out, so a missing id is never mistaken for an absent one.
230
+ */
231
+ const ID_BYTES = 256;
232
+ const LABEL_ID_BYTES = 128;
233
+ const ATTACHMENT_ID_BYTES = 1024;
234
+ function idOf(value, maxBytes = ID_BYTES) {
235
+ const id = text(value);
236
+ return id !== undefined && jsonBytes(id) <= maxBytes ? id : undefined;
237
+ }
238
+ /**
239
+ * The usable ids among `fields`, plus `omittedIds` naming each field Gmail
240
+ * sent an id for that was too long to return.
241
+ */
242
+ function ids(fields) {
243
+ const kept = {};
244
+ const omitted = [];
245
+ for (const [name, [value, maxBytes]] of Object.entries(fields)) {
246
+ const id = idOf(value, maxBytes);
247
+ if (id !== undefined)
248
+ kept[name] = id;
249
+ else if (text(value) !== undefined)
250
+ omitted.push(name);
251
+ }
252
+ return omitted.length > 0 ? { ...kept, omittedIds: omitted } : kept;
253
+ }
254
+ /** At most this many label ids per message; the rest are counted. */
255
+ const MAX_LISTED_LABELS = 20;
256
+ /** Usable label ids, at most twenty, and how many were left out either way. */
257
+ function labelsOf(labelIds) {
258
+ const usable = labelIds.filter((id) => idOf(id, LABEL_ID_BYTES) !== undefined);
259
+ const listed = usable.slice(0, MAX_LISTED_LABELS);
260
+ const omitted = labelIds.length - listed.length;
261
+ return omitted > 0 ? { labelIds: listed, labelIdsOmitted: omitted } : { labelIds: listed };
262
+ }
263
+ /** Attachment rows listed per message; the rest are counted, not listed. */
264
+ const MAX_LISTED_ATTACHMENTS = 20;
265
+ function attachmentsOf(payload) {
266
+ const all = walk(payload).attachments;
267
+ return {
268
+ listed: all.slice(0, MAX_LISTED_ATTACHMENTS).map((part) => compact({
269
+ filename: clipped(text(part["filename"]) ?? "", FIELD_BYTES),
270
+ mimeType: clipped(text(part["mimeType"]), FIELD_BYTES),
271
+ size: typeof asRecord(part["body"])["size"] === "number" ? asRecord(part["body"])["size"] : undefined,
272
+ ...ids({ attachmentId: [asRecord(part["body"])["attachmentId"], ATTACHMENT_ID_BYTES] }),
273
+ })),
274
+ omitted: Math.max(0, all.length - MAX_LISTED_ATTACHMENTS),
275
+ };
276
+ }
277
+ /**
278
+ * Per-field byte bounds, so every result is bounded by construction: a page
279
+ * of 25 summaries, or one message beside its body, stays under
280
+ * `RESULT_BUDGET_BYTES` however long and however encoded Google's strings are.
281
+ */
282
+ const FIELD_BYTES = 512;
283
+ const HEADER_BYTES = 4 * 1024;
284
+ /** A string cut to `maxBytes` of JSON, ending in "…" when it was cut. */
285
+ function clipped(value, maxBytes) {
286
+ return (value === undefined ? value : clampText(value, maxBytes, () => "…"));
287
+ }
288
+ /** Cut a body at `max` characters and say so in the text itself. */
289
+ function capped(body, max) {
290
+ if (body.length <= max)
291
+ return { body, bodyTruncated: false };
292
+ const rest = body.length - max;
293
+ return {
294
+ body: `${body.slice(0, max)}\n[… ${rest} more characters truncated; raise maxBodyChars to read them]`,
295
+ bodyTruncated: true,
296
+ };
297
+ }
298
+ function isoFromMillis(value) {
299
+ const millis = typeof value === "string" ? Number(value) : value;
300
+ if (typeof millis !== "number" || !Number.isFinite(millis))
301
+ return undefined;
302
+ const date = new Date(millis);
303
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
304
+ }
305
+ function labelIdsOf(message) {
306
+ return asArray(message["labelIds"]).filter((id) => typeof id === "string");
307
+ }
308
+ /**
309
+ * One message, projected and finished into the result it belongs to, with
310
+ * its body cut so that the finished result — measured as it will be sent, not
311
+ * estimated — fits `budget`. `finish` shapes the message into what the tool
312
+ * returns (a draft's wrapper, say); the identity for a bare message.
313
+ */
314
+ async function projectMessage(client, ctx, value, maxBodyChars, budget = RESULT_BUDGET_BYTES, finish = (message) => message) {
315
+ const message = asRecord(value);
316
+ const payload = asRecord(message["payload"]);
317
+ const read = await readBody(client, ctx, idOf(message["id"]), payload, maxBodyChars);
318
+ const attachments = attachmentsOf(payload);
319
+ const headerOf = (name) => clipped(header(payload, name), HEADER_BYTES);
320
+ const { omittedIds, ...messageIds } = ids({ id: [message["id"]], threadId: [message["threadId"]] });
321
+ const projected = compact({
322
+ ...messageIds,
323
+ ...labelsOf(labelIdsOf(message)),
324
+ date: isoFromMillis(message["internalDate"]),
325
+ from: headerOf("From"),
326
+ to: headerOf("To"),
327
+ cc: headerOf("Cc"),
328
+ bcc: headerOf("Bcc"),
329
+ replyTo: headerOf("Reply-To"),
330
+ subject: headerOf("Subject"),
331
+ messageIdHeader: headerOf("Message-ID"),
332
+ inReplyTo: headerOf("In-Reply-To"),
333
+ snippet: text(message["snippet"]) ? clipped(decodeEntities(message["snippet"]), HEADER_BYTES) : undefined,
334
+ body: "",
335
+ bodyTruncated: false,
336
+ bodyFormat: read.bodyFormat,
337
+ attachments: attachments.listed.length > 0 ? attachments.listed : undefined,
338
+ attachmentsOmitted: attachments.omitted > 0 ? attachments.omitted : undefined,
339
+ omittedIds,
340
+ });
341
+ if (!("text" in read))
342
+ return finish({ ...projected, body: read.body, bodyTruncated: read.bodyTruncated });
343
+ // Cap by the caller's characters, then by bytes: cut, measure the finished
344
+ // result, and cut again by exactly what it is over, until it fits. Every
345
+ // field is counted as sent — `false` is a byte longer than `true` — so a
346
+ // body at the exact boundary is not a byte over.
347
+ const byCharacters = capped(read.text, maxBodyChars);
348
+ const build = (body) => finish({ ...projected, body, bodyTruncated: byCharacters.bodyTruncated || body !== byCharacters.body });
349
+ let body = byCharacters.body;
350
+ let result = build(body);
351
+ for (let round = 0; round < 8; round += 1) {
352
+ const over = jsonBytes(result) - budget;
353
+ if (over <= 0)
354
+ return result;
355
+ const target = Math.max(2, jsonBytes(body) - over);
356
+ const shorter = clampText(byCharacters.body, target, resultLimitMarker);
357
+ if (shorter === body)
358
+ break;
359
+ body = shorter;
360
+ result = build(body);
361
+ }
362
+ // Nothing left to cut: the metadata alone is past the budget, and the
363
+ // tool's final size check refuses it rather than sending it.
364
+ return result;
365
+ }
366
+ /** Longest cursor this connection issues; the schemas accept no longer. */
367
+ const MAX_CURSOR_LENGTH = 2048;
368
+ async function digest(value) {
369
+ const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)));
370
+ return Array.from(bytes.subarray(0, 16), (byte) => byte.toString(16).padStart(2, "0")).join("");
371
+ }
372
+ /** The digest naming one call: its tool and the arguments that shape its pages. */
373
+ function callKey(tool, shape) {
374
+ return digest(JSON.stringify([tool, shape]));
375
+ }
376
+ function encodeCursor(claims) {
377
+ const bytes = new TextEncoder().encode(JSON.stringify(claims));
378
+ let binary = "";
379
+ for (const byte of bytes)
380
+ binary += String.fromCharCode(byte);
381
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
382
+ }
383
+ /** The claims of a cursor this call may resume, or a refusal saying why not. */
384
+ function openCursor(cursor, tool, call) {
385
+ if (cursor === undefined)
386
+ return undefined;
387
+ let claims;
388
+ try {
389
+ const parsed = JSON.parse(new TextDecoder().decode(base64UrlBytes(String(cursor))));
390
+ if (parsed && typeof parsed === "object" && parsed.v === 1)
391
+ claims = parsed;
392
+ }
393
+ catch {
394
+ claims = undefined;
395
+ }
396
+ if (!claims || typeof claims.t !== "string" || typeof claims.c !== "string") {
397
+ throw new ConnectorCallError("invalid_args", "That is not a cursor this connection issued. Start again without one.");
398
+ }
399
+ if (claims.t !== tool || claims.c !== call) {
400
+ 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.");
401
+ }
402
+ return claims;
403
+ }
404
+ /** Where to resume in `ids`, checked against what the earlier pages saw. */
405
+ async function resumeAt(ids, claims, what) {
406
+ if (!claims)
407
+ return 0;
408
+ const seen = claims.n;
409
+ if (typeof seen !== "number" ||
410
+ !Number.isInteger(seen) ||
411
+ seen < 1 ||
412
+ seen > ids.length ||
413
+ claims.f !== (await digest(JSON.stringify(ids.slice(0, seen))))) {
414
+ throw new ConnectorCallError("conflict", `The ${what} changed since the previous page: an item was added or removed before where that page ended. Read it again from the start, without a cursor.`);
415
+ }
416
+ return seen;
417
+ }
418
+ async function cursorAfter(ids, seen, tool, call) {
419
+ return encodeCursor({ v: 1, t: tool, c: call, n: seen, f: await digest(JSON.stringify(ids.slice(0, seen))) });
420
+ }
421
+ /** A page object whose cursor is as long as any this connection issues. */
422
+ const RESERVED_PAGE = { hasMore: true, nextCursor: "x".repeat(MAX_CURSOR_LENGTH) };
423
+ function resultLimitMarker(dropped) {
424
+ return `\n[… ${dropped} more characters truncated at connecta's ${RESULT_BUDGET_BYTES / 1024} KiB per-result limit; read the rest in Gmail]`;
425
+ }
426
+ /**
427
+ * Every Gmail result's last check: its whole serialized size, wrapper and
428
+ * all, against the budget. The bounds above make a refusal here a
429
+ * should-not-happen; it exists so nothing oversized is ever handed on.
430
+ */
431
+ function deliverable(tool, result) {
432
+ const size = jsonBytes(result);
433
+ if (size > RESULT_BUDGET_BYTES) {
434
+ throw new ConnectorCallError("connector_call_failed", `Gmail's answer to ${tool} is ${size} bytes even after cutting, past connecta's ${RESULT_BUDGET_BYTES / 1024} KiB result limit. Open it in Gmail.`, { retryable: false });
435
+ }
436
+ return result;
437
+ }
438
+ function projectThreadSummary(value, listedSnippet) {
439
+ const thread = asRecord(value);
440
+ const messages = asArray(thread["messages"]).map(asRecord);
441
+ const first = asRecord(messages[0]?.["payload"]);
442
+ const last = asRecord(messages[messages.length - 1]?.["payload"]);
443
+ const labels = new Set();
444
+ let latest;
445
+ for (const message of messages) {
446
+ for (const id of labelIdsOf(message))
447
+ labels.add(id);
448
+ const at = Number(message["internalDate"]);
449
+ if (Number.isFinite(at) && (latest === undefined || at > latest))
450
+ latest = at;
451
+ }
452
+ const snippet = text(listedSnippet) ?? text(messages[messages.length - 1]?.["snippet"]);
453
+ return compact({
454
+ ...ids({ id: [thread["id"]] }),
455
+ subject: clipped(header(first, "Subject"), FIELD_BYTES),
456
+ from: clipped(header(first, "From"), FIELD_BYTES),
457
+ lastFrom: messages.length > 1 ? clipped(header(last, "From"), FIELD_BYTES) : undefined,
458
+ messageCount: messages.length,
459
+ lastMessageAt: isoFromMillis(latest),
460
+ unread: labels.has("UNREAD"),
461
+ ...labelsOf([...labels]),
462
+ snippet: snippet ? clipped(decodeEntities(snippet), FIELD_BYTES) : undefined,
463
+ });
464
+ }
465
+ function projectDraftSummary(value) {
466
+ const draft = asRecord(value);
467
+ const message = asRecord(draft["message"]);
468
+ const payload = asRecord(message["payload"]);
469
+ return compact({
470
+ ...ids({ draftId: [draft["id"]], messageId: [message["id"]], threadId: [message["threadId"]] }),
471
+ subject: clipped(header(payload, "Subject"), FIELD_BYTES),
472
+ to: clipped(header(payload, "To"), FIELD_BYTES),
473
+ updatedAt: isoFromMillis(message["internalDate"]),
474
+ snippet: text(message["snippet"]) ? clipped(decodeEntities(message["snippet"]), FIELD_BYTES) : undefined,
475
+ });
476
+ }
477
+ /** Run `work` over `items`, at most `limit` at a time, keeping order. */
478
+ async function mapLimited(items, limit, work) {
479
+ const results = [];
480
+ let next = 0;
481
+ const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
482
+ while (next < items.length) {
483
+ const index = next;
484
+ next += 1;
485
+ results[index] = await work(items[index]);
486
+ }
487
+ });
488
+ await Promise.all(lanes);
489
+ return results;
490
+ }
491
+ /**
492
+ * One row's summary read, or nothing when the item was deleted between the
493
+ * list and the read — a page that fails on a race nobody can repeat helps no
494
+ * one, and the row's absence is the truth by then.
495
+ */
496
+ async function unlessGone(read) {
497
+ try {
498
+ return await read();
499
+ }
500
+ catch (error) {
501
+ if (error instanceof ConnectorCallError && error.code === "not_found")
502
+ return undefined;
503
+ throw error;
504
+ }
505
+ }
506
+ // --- Writing drafts ---------------------------------------------------------------
507
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
508
+ const MAILBOX = /^[^\s@<>()",;:\\[\]]+@[^\s@<>()",;:\\[\]]+$/;
509
+ function refuseLineBreaks(name, value) {
510
+ if (/[\r\n]/.test(value) || value.includes("\u0000")) {
511
+ throw new ConnectorCallError("invalid_args", `${name} may not contain a line break.`);
512
+ }
513
+ }
514
+ /**
515
+ * RFC 2047 encoded words for header text that is not plain ASCII. Each word
516
+ * carries at most 45 UTF-8 bytes (60 base64 characters, 72 with its armor),
517
+ * split only between code points, and words fold onto continuation lines.
518
+ */
519
+ function encodedWords(value) {
520
+ if (PRINTABLE_ASCII.test(value))
521
+ return value;
522
+ const encoder = new TextEncoder();
523
+ const words = [];
524
+ let chunk = "";
525
+ let size = 0;
526
+ for (const character of value) {
527
+ const bytes = encoder.encode(character).length;
528
+ if (size + bytes > 45 && chunk) {
529
+ words.push(chunk);
530
+ chunk = "";
531
+ size = 0;
532
+ }
533
+ chunk += character;
534
+ size += bytes;
535
+ }
536
+ if (chunk)
537
+ words.push(chunk);
538
+ return words.map((word) => `=?UTF-8?B?${base64(encoder.encode(word))}?=`).join("\r\n ");
539
+ }
540
+ /** `Name <addr>` or `addr`, display name encoded or quoted as needed. */
541
+ function formatAddress(field, raw) {
542
+ refuseLineBreaks(field, raw);
543
+ const trimmed = raw.trim();
544
+ const named = /^(.*?)\s*<([^<>]+)>$/.exec(trimmed);
545
+ const address = (named ? named[2] : trimmed).trim();
546
+ if (!MAILBOX.test(address) || !PRINTABLE_ASCII.test(address)) {
547
+ throw new ConnectorCallError("invalid_args", `${field} has an entry that is not an email address; use "name@example.com" or "Name <name@example.com>".`);
548
+ }
549
+ const display = named?.[1]?.trim() ?? "";
550
+ const quoted = /^"((?:[^"\\]|\\.)*)"$/.exec(display);
551
+ const name = (quoted ? quoted[1].replace(/\\(.)/g, "$1") : display).trim();
552
+ if (!name)
553
+ return address;
554
+ if (!PRINTABLE_ASCII.test(name))
555
+ return `${encodedWords(name)} <${address}>`;
556
+ return /^[A-Za-z0-9 !#$%&'*+\-/=?^_`{|}~]+$/.test(name)
557
+ ? `${name} <${address}>`
558
+ : `"${name.replace(/(["\\])/g, "\\$1")}" <${address}>`;
559
+ }
560
+ function base64(bytes) {
561
+ let binary = "";
562
+ for (let index = 0; index < bytes.length; index += 0x8000) {
563
+ binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
564
+ }
565
+ return btoa(binary);
566
+ }
567
+ /** Base64 body lines of 76 characters, as RFC 2045 requires. */
568
+ function base64Lines(value) {
569
+ return (base64(new TextEncoder().encode(value)).match(/.{1,76}/g) ?? []).join("\r\n");
570
+ }
571
+ /**
572
+ * Split an address header into its mailboxes, as RFC 5322 reads it: commas
573
+ * inside a quoted display name, an angle address, or a comment do not
574
+ * separate, comments are dropped, and a group (`Team: a@x, b@y;`) contributes
575
+ * its members. Each mailbox is then checked; a header that does not parse is
576
+ * refused rather than guessed at, because a wrong split sends mail to the
577
+ * wrong people.
578
+ */
579
+ function addressEntries(field, value) {
580
+ if (!value)
581
+ return [];
582
+ const entries = [];
583
+ let current = "";
584
+ let quoted = false;
585
+ let escaped = false;
586
+ let angle = 0;
587
+ let comment = 0;
588
+ let group = false;
589
+ const flush = () => {
590
+ const entry = current.replace(/\s+/g, " ").trim();
591
+ if (entry)
592
+ entries.push(entry);
593
+ current = "";
594
+ };
595
+ for (const character of value) {
596
+ if (escaped) {
597
+ current += character;
598
+ escaped = false;
599
+ }
600
+ else if (quoted) {
601
+ if (character === "\\")
602
+ escaped = true;
603
+ else if (character === '"')
604
+ quoted = false;
605
+ current += character;
606
+ }
607
+ else if (comment > 0) {
608
+ if (character === "(")
609
+ comment += 1;
610
+ else if (character === ")")
611
+ comment -= 1;
612
+ }
613
+ else if (character === '"') {
614
+ quoted = true;
615
+ current += character;
616
+ }
617
+ else if (character === "(") {
618
+ comment = 1;
619
+ }
620
+ else if (character === "<") {
621
+ angle += 1;
622
+ current += character;
623
+ }
624
+ else if (character === ">") {
625
+ angle -= 1;
626
+ current += character;
627
+ }
628
+ else if (angle === 0 && character === ":" && !group) {
629
+ // A group's display name names nobody; its members follow.
630
+ group = true;
631
+ current = "";
632
+ }
633
+ else if (angle === 0 && character === ";" && group) {
634
+ group = false;
635
+ flush();
636
+ }
637
+ else if (angle === 0 && character === ",") {
638
+ flush();
639
+ }
640
+ else {
641
+ current += character;
642
+ }
643
+ if (angle < 0 || angle > 1)
644
+ break;
645
+ }
646
+ if (quoted || escaped || angle !== 0 || comment !== 0 || group) {
647
+ throw new ConnectorCallError("invalid_args", `${field} is not a well-formed address list.`);
648
+ }
649
+ flush();
650
+ // Validate each one now, with this field's name, before anything is built.
651
+ for (const entry of entries)
652
+ formatAddress(field, entry);
653
+ return entries;
654
+ }
655
+ /**
656
+ * One RFC 5322 message, base64url-encoded as Gmail's `raw` field wants it.
657
+ * `Date` and `Message-ID` are Gmail's to set, and so is `From` on a new draft:
658
+ * Gmail fills it with the mailbox's own address. An update keeps the `From`
659
+ * the draft already had — a send-as alias the user chose in Gmail — and no
660
+ * argument can set one, so an agent cannot forge a sender.
661
+ */
662
+ function buildRawMessage(message) {
663
+ const lines = [];
664
+ for (const [name, list] of [
665
+ ["From", message.from],
666
+ ["To", message.to],
667
+ ["Cc", message.cc],
668
+ ["Bcc", message.bcc],
669
+ ["Reply-To", message.replyTo],
670
+ ]) {
671
+ if (list.length === 0)
672
+ continue;
673
+ if (list.length > MAX_RECIPIENTS) {
674
+ throw new ConnectorCallError("invalid_args", `${name} lists more than ${MAX_RECIPIENTS} recipients.`);
675
+ }
676
+ lines.push(`${name}: ${list.map((entry) => formatAddress(name.toLowerCase(), entry)).join(",\r\n ")}`);
677
+ }
678
+ if (message.subject !== undefined) {
679
+ refuseLineBreaks("subject", message.subject);
680
+ lines.push(`Subject: ${encodedWords(message.subject)}`);
681
+ }
682
+ if (message.inReplyTo)
683
+ lines.push(`In-Reply-To: ${message.inReplyTo}`);
684
+ if (message.references)
685
+ lines.push(`References: ${message.references}`);
686
+ lines.push("MIME-Version: 1.0");
687
+ const plain = [
688
+ 'Content-Type: text/plain; charset="UTF-8"',
689
+ "Content-Transfer-Encoding: base64",
690
+ "",
691
+ base64Lines(message.body),
692
+ ];
693
+ if (message.htmlBody === undefined) {
694
+ lines.push(...plain);
695
+ }
696
+ else {
697
+ const boundary = `connecta-${crypto.randomUUID()}`;
698
+ 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}--`);
699
+ }
700
+ return base64(new TextEncoder().encode(`${lines.join("\r\n")}\r\n`))
701
+ .replace(/\+/g, "-")
702
+ .replace(/\//g, "_")
703
+ .replace(/=+$/, "");
704
+ }
705
+ /** Message-ID tokens (`<…@…>`) in a header, in order, line breaks gone. */
706
+ function messageIds(value) {
707
+ return value?.match(/<[^<>\s]+>/g) ?? [];
708
+ }
709
+ /** At most this many ancestors ride in References; the newest are kept. */
710
+ const MAX_REFERENCES = 20;
711
+ /** Threading for a reply: the answered message's thread, ids, and sender. */
712
+ async function replyThreading(client, messageId, ctx) {
713
+ const message = asRecord(await client.json({
714
+ method: "GET",
715
+ path: `/messages/${encodeURIComponent(messageId)}`,
716
+ query: {
717
+ format: "metadata",
718
+ metadataHeaders: ["Message-ID", "References", "Subject", "From", "Reply-To"],
719
+ },
720
+ }, ctx));
721
+ const payload = asRecord(message["payload"]);
722
+ const id = messageIds(header(payload, "Message-ID"))[0];
723
+ const chain = [...messageIds(header(payload, "References")), ...(id ? [id] : [])].slice(-MAX_REFERENCES);
724
+ const subject = oneLine(header(payload, "Subject"));
725
+ const replyTo = header(payload, "Reply-To");
726
+ return {
727
+ threadId: text(message["threadId"]),
728
+ inReplyTo: id,
729
+ references: chain.length > 0 ? chain.join("\r\n ") : undefined,
730
+ subject: subject === undefined ? undefined : /^re:/i.test(subject) ? subject : `Re: ${subject}`,
731
+ // Parsed only when the caller did not name recipients, so a sender header
732
+ // this connection cannot read never blocks a reply addressed explicitly.
733
+ recipients: () => addressEntries(`The replied-to message's ${replyTo ? "Reply-To" : "From"} (pass to explicitly)`, oneLine(replyTo ?? header(payload, "From"))),
734
+ };
735
+ }
736
+ /** A header value read back from Gmail, unfolded before it is written again. */
737
+ function oneLine(value) {
738
+ return value?.replace(/[\r\n]+\s*/g, " ").trim();
739
+ }
740
+ /**
741
+ * A draft's id, its message id, and the thread Gmail placed it in. Gmail
742
+ * answered 2xx, so the draft was saved whatever its answer looks like: an id
743
+ * it sent that cannot be returned, or none at all, still reads as saved —
744
+ * with `draftId` absent, `omittedIds` or `note` saying why, and where to find
745
+ * it — never as a failure that invites saving it again.
746
+ */
747
+ function projectSavedDraft(value) {
748
+ const draft = asRecord(value);
749
+ const message = asRecord(draft["message"]);
750
+ const saved = ids({ draftId: [draft["id"]], messageId: [message["id"]], threadId: [message["threadId"]] });
751
+ return compact({
752
+ ...saved,
753
+ saved: true,
754
+ sent: false,
755
+ note: saved["draftId"] === undefined
756
+ ? "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."
757
+ : undefined,
758
+ });
759
+ }
760
+ function addressList(value) {
761
+ return Array.isArray(value) ? value.map(String) : undefined;
762
+ }
763
+ /** The MIME types update_draft can rebuild: text, in any multipart shape. */
764
+ const REBUILDABLE = new Set(["text/plain", "text/html", "multipart/alternative", "multipart/mixed"]);
765
+ /**
766
+ * What in an existing draft update_draft could not carry over, by name.
767
+ * Gmail's update replaces the whole message, so anything the rebuilt message
768
+ * would not contain — an attachment, an inline image, a forwarded message, a
769
+ * calendar invitation — would be deleted for good. Such a draft is refused
770
+ * instead.
771
+ */
772
+ function unrebuildable(payload) {
773
+ const found = [];
774
+ const visit = (part, depth) => {
775
+ const mime = mimeOf(part);
776
+ if (isAttachment(part) || header(part, "Content-ID") || (mime && !REBUILDABLE.has(mime))) {
777
+ found.push(text(part["filename"]) ?? (mime || "an unnamed part"));
778
+ return;
779
+ }
780
+ const children = asArray(part["parts"]);
781
+ // Past the depth this inspects, what lies below is unknown, and unknown
782
+ // content is content the update could delete: refuse rather than assume.
783
+ if (depth >= MAX_PART_DEPTH && children.length > 0) {
784
+ found.push(`parts nested more than ${MAX_PART_DEPTH} levels deep`);
785
+ return;
786
+ }
787
+ for (const child of children)
788
+ visit(asRecord(child), depth + 1);
789
+ };
790
+ visit(payload, 0);
791
+ return found;
792
+ }
793
+ // --- Schemas ----------------------------------------------------------------------
794
+ function input(properties, required) {
795
+ return { type: "object", properties, required, additionalProperties: false };
796
+ }
797
+ const ID_PATTERN = "^[A-Za-z0-9_-]+$";
798
+ function idProperty(description) {
799
+ return { type: "string", minLength: 1, maxLength: 256, pattern: ID_PATTERN, description };
800
+ }
801
+ const CURSOR_PROPERTY = {
802
+ type: "string",
803
+ minLength: 1,
804
+ maxLength: MAX_CURSOR_LENGTH,
805
+ pattern: "^[A-Za-z0-9_-]+$",
806
+ description: "Opaque page.nextCursor from the previous page of this same call; another call's cursor is refused. Pass it back unchanged.",
807
+ };
808
+ const LIMIT_PROPERTY = {
809
+ type: "integer",
810
+ minimum: 1,
811
+ maximum: MAX_PAGE_SIZE,
812
+ description: `Rows per page, 1 to ${MAX_PAGE_SIZE}; defaults to ${DEFAULT_PAGE_SIZE}. Connecta's cap, below Gmail's 500, because each row costs a metadata read.`,
813
+ };
814
+ const QUERY_PROPERTY = {
815
+ type: "string",
816
+ maxLength: 2048,
817
+ description: "Gmail search syntax, as in the Gmail search box: from:, to:, subject:, label:, is:unread, has:attachment, newer_than:7d, in:inbox. Omit for everything.",
818
+ };
819
+ function bodyCharsProperty(fallback) {
820
+ return {
821
+ type: "integer",
822
+ minimum: 0,
823
+ maximum: MAX_BODY_CHARS,
824
+ description: `Characters of body text kept per message, 0 to ${MAX_BODY_CHARS}; defaults to ${fallback}. Longer bodies end with a truncation marker, as does any body past connecta's ${RESULT_BUDGET_BYTES / 1024} KiB result limit.`,
825
+ };
826
+ }
827
+ const ADDRESSES = (who) => ({
828
+ type: "array",
829
+ maxItems: MAX_RECIPIENTS,
830
+ items: {
831
+ type: "string",
832
+ minLength: 3,
833
+ maxLength: 320,
834
+ pattern: "^[^\\r\\n]*$",
835
+ description: '"name@example.com" or "Name <name@example.com>".',
836
+ },
837
+ description: `${who} recipients.`,
838
+ });
839
+ const SUBJECT_PROPERTY = {
840
+ type: "string",
841
+ maxLength: 900,
842
+ pattern: "^[^\\r\\n]*$",
843
+ description: "Subject line; any language. A reply defaults to Re: and the original subject, which Gmail needs to keep it in the thread.",
844
+ };
845
+ const BODY_PROPERTY = {
846
+ type: "string",
847
+ maxLength: 200_000,
848
+ description: "Plain-text body. Always required; it is what clients without HTML show.",
849
+ };
850
+ const HTML_BODY_PROPERTY = {
851
+ type: "string",
852
+ maxLength: 500_000,
853
+ description: "Optional HTML alternative to body, sent beside it. Omit for a plain-text draft.",
854
+ };
855
+ const REPLY_PROPERTY = idProperty("Message id (from get_thread or get_message) this draft replies to: sets its thread, In-Reply-To, and References, and defaults subject and to from it.");
856
+ const PAGE_SCHEMA = {
857
+ type: "object",
858
+ properties: {
859
+ hasMore: { type: "boolean" },
860
+ nextCursor: { type: ["string", "null"] },
861
+ },
862
+ required: ["hasMore", "nextCursor"],
863
+ };
864
+ /** Fields Gmail sent an id for that was too long to return; see `ids()`. */
865
+ const OMITTED_IDS_SCHEMA = { type: "array", items: { type: "string" } };
866
+ const ATTACHMENT_SCHEMA = {
867
+ type: "object",
868
+ properties: {
869
+ filename: { type: "string" },
870
+ mimeType: { type: "string" },
871
+ size: { type: "integer" },
872
+ attachmentId: { type: "string" },
873
+ omittedIds: OMITTED_IDS_SCHEMA,
874
+ },
875
+ };
876
+ const MESSAGE_PROPERTIES = {
877
+ id: { type: "string" },
878
+ threadId: { type: "string" },
879
+ labelIds: { type: "array", items: { type: "string" } },
880
+ labelIdsOmitted: { type: "integer" },
881
+ omittedIds: OMITTED_IDS_SCHEMA,
882
+ date: { type: "string" },
883
+ from: { type: "string" },
884
+ to: { type: "string" },
885
+ cc: { type: "string" },
886
+ bcc: { type: "string" },
887
+ replyTo: { type: "string" },
888
+ subject: { type: "string" },
889
+ messageIdHeader: { type: "string" },
890
+ inReplyTo: { type: "string" },
891
+ snippet: { type: "string" },
892
+ body: { type: "string" },
893
+ bodyTruncated: { type: "boolean" },
894
+ bodyFormat: { type: "string", enum: ["text", "html", "none", "unavailable"] },
895
+ attachments: { type: "array", items: ATTACHMENT_SCHEMA },
896
+ attachmentsOmitted: { type: "integer" },
897
+ };
898
+ const MESSAGE_SCHEMA = { type: "object", properties: MESSAGE_PROPERTIES };
899
+ /** A draft names its message `messageId`, beside its own `draftId`. */
900
+ const { id: _messageId, ...DRAFT_MESSAGE_PROPERTIES } = MESSAGE_PROPERTIES;
901
+ const SAVED_DRAFT_SCHEMA = {
902
+ type: "object",
903
+ properties: {
904
+ draftId: { type: "string" },
905
+ messageId: { type: "string" },
906
+ threadId: { type: "string" },
907
+ saved: { type: "boolean", const: true },
908
+ sent: { type: "boolean", const: false },
909
+ omittedIds: OMITTED_IDS_SCHEMA,
910
+ note: { type: "string" },
911
+ },
912
+ required: ["saved", "sent"],
913
+ };
914
+ // --- Tools ------------------------------------------------------------------------
915
+ function tools(client) {
916
+ const readOnly = { readOnlyHint: true };
917
+ const bodyChars = (args, fallback) => typeof args["maxBodyChars"] === "number" ? args["maxBodyChars"] : fallback;
918
+ // Gmail's own page token, carried inside a cursor bound to this call.
919
+ const gmailPage = (listing, tool, call) => {
920
+ const token = text(listing["nextPageToken"]);
921
+ if (token === undefined)
922
+ return { hasMore: false, nextCursor: null };
923
+ const cursor = encodeCursor({ v: 1, t: tool, c: call, p: token });
924
+ if (cursor.length > MAX_CURSOR_LENGTH) {
925
+ 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 });
926
+ }
927
+ return { hasMore: true, nextCursor: cursor };
928
+ };
929
+ const compose = async (args, ctx, kept) => {
930
+ const reply = args["replyToMessageId"] ? await replyThreading(client, String(args["replyToMessageId"]), ctx) : kept;
931
+ // What the caller states wins; then what the draft already had; then
932
+ // what a reply defaults to. A new draft has nothing kept.
933
+ const raw = buildRawMessage({
934
+ from: kept.from ?? [],
935
+ to: addressList(args["to"]) ?? (kept.to?.length ? kept.to : reply.recipients?.()) ?? [],
936
+ cc: addressList(args["cc"]) ?? kept.cc ?? [],
937
+ bcc: addressList(args["bcc"]) ?? kept.bcc ?? [],
938
+ replyTo: kept.replyTo ?? [],
939
+ subject: args["subject"] ?? kept.subject ?? reply.subject,
940
+ body: String(args["body"] ?? ""),
941
+ htmlBody: typeof args["htmlBody"] === "string" ? args["htmlBody"] : undefined,
942
+ inReplyTo: reply.inReplyTo,
943
+ references: reply.references,
944
+ });
945
+ return { message: compact({ raw, threadId: reply.threadId }) };
946
+ };
947
+ return [
948
+ {
949
+ name: "search_threads",
950
+ description: "Search the user's Gmail threads with Gmail query syntax, newest first, returning subject, senders, date, and snippet per thread. Bodies need get_thread.",
951
+ annotations: readOnly,
952
+ inputSchema: input({
953
+ query: QUERY_PROPERTY,
954
+ includeSpamTrash: {
955
+ type: "boolean",
956
+ description: "Include Spam and Trash, which Gmail leaves out by default.",
957
+ },
958
+ limit: LIMIT_PROPERTY,
959
+ cursor: CURSOR_PROPERTY,
960
+ }, []),
961
+ outputSchema: {
962
+ type: "object",
963
+ properties: {
964
+ threads: {
965
+ type: "array",
966
+ items: {
967
+ type: "object",
968
+ properties: {
969
+ id: { type: "string" },
970
+ subject: { type: "string" },
971
+ from: { type: "string" },
972
+ lastFrom: { type: "string" },
973
+ messageCount: { type: "integer" },
974
+ lastMessageAt: { type: "string" },
975
+ unread: { type: "boolean" },
976
+ labelIds: { type: "array", items: { type: "string" } },
977
+ labelIdsOmitted: { type: "integer" },
978
+ omittedIds: OMITTED_IDS_SCHEMA,
979
+ snippet: { type: "string" },
980
+ },
981
+ },
982
+ },
983
+ resultSizeEstimate: { type: "integer" },
984
+ page: PAGE_SCHEMA,
985
+ },
986
+ required: ["threads", "page"],
987
+ },
988
+ handler: async (args, ctx) => {
989
+ const limit = args["limit"] ?? DEFAULT_PAGE_SIZE;
990
+ const call = await callKey("search_threads", {
991
+ query: args["query"] ?? null,
992
+ includeSpamTrash: args["includeSpamTrash"] === true,
993
+ limit,
994
+ });
995
+ const claims = openCursor(args["cursor"], "search_threads", call);
996
+ const listing = asRecord(await client.json({
997
+ method: "GET",
998
+ path: "/threads",
999
+ query: {
1000
+ q: args["query"],
1001
+ includeSpamTrash: args["includeSpamTrash"],
1002
+ maxResults: limit,
1003
+ pageToken: claims?.p,
1004
+ },
1005
+ }, ctx));
1006
+ const rows = asArray(listing["threads"]).map(asRecord);
1007
+ const threads = await mapLimited(rows, SUMMARY_CONCURRENCY, (row) => unlessGone(async () => projectThreadSummary(await client.json({
1008
+ method: "GET",
1009
+ path: `/threads/${encodeURIComponent(String(row["id"]))}`,
1010
+ query: { format: "metadata", metadataHeaders: ["Subject", "From", "Date"] },
1011
+ }, ctx), row["snippet"])));
1012
+ return compact({
1013
+ threads: threads.filter((thread) => thread !== undefined),
1014
+ resultSizeEstimate: typeof listing["resultSizeEstimate"] === "number" ? listing["resultSizeEstimate"] : undefined,
1015
+ page: gmailPage(listing, "search_threads", call),
1016
+ });
1017
+ },
1018
+ },
1019
+ {
1020
+ name: "get_thread",
1021
+ description: "Get a Gmail thread's messages, oldest first, with headers, plain-text bodies capped by maxBodyChars, and attachment names. A long thread pages by cursor.",
1022
+ annotations: readOnly,
1023
+ inputSchema: input({
1024
+ threadId: idProperty("Thread id from search_threads or a message's threadId."),
1025
+ maxBodyChars: bodyCharsProperty(DEFAULT_THREAD_BODY_CHARS),
1026
+ cursor: {
1027
+ ...CURSOR_PROPERTY,
1028
+ description: `Opaque page.nextCursor from this thread's previous page, same maxBodyChars. A page ends before ${RESULT_BUDGET_BYTES / 1024} KiB; a thread changed before that point answers conflict.`,
1029
+ },
1030
+ }, ["threadId"]),
1031
+ outputSchema: {
1032
+ type: "object",
1033
+ properties: {
1034
+ id: { type: "string" },
1035
+ messageCount: { type: "integer" },
1036
+ messages: { type: "array", items: MESSAGE_SCHEMA },
1037
+ page: PAGE_SCHEMA,
1038
+ },
1039
+ required: ["id", "messages", "page"],
1040
+ },
1041
+ handler: async (args, ctx) => {
1042
+ const thread = asRecord(await client.json({
1043
+ method: "GET",
1044
+ path: `/threads/${encodeURIComponent(args["threadId"])}`,
1045
+ query: { format: "full" },
1046
+ }, ctx));
1047
+ const max = bodyChars(args, DEFAULT_THREAD_BODY_CHARS);
1048
+ const call = await callKey("get_thread", { threadId: args["threadId"], maxBodyChars: max });
1049
+ const claims = openCursor(args["cursor"], "get_thread", call);
1050
+ const all = asArray(thread["messages"]);
1051
+ const ids = all.map((message) => String(asRecord(message)["id"] ?? ""));
1052
+ const start = await resumeAt(ids, claims, "thread");
1053
+ // The whole result is budgeted: the wrapper (with the longest cursor
1054
+ // this connection issues) first, then messages and their separators
1055
+ // until the next would not fit. A message alone is projected to fit
1056
+ // beside the wrapper, so a page holds at least one and paging ends.
1057
+ const threadId = idOf(thread["id"]) ?? args["threadId"];
1058
+ let bytes = jsonBytes({ id: threadId, messageCount: all.length, messages: [], page: RESERVED_PAGE });
1059
+ const room = RESULT_BUDGET_BYTES - bytes;
1060
+ const messages = [];
1061
+ let next = start;
1062
+ while (next < all.length) {
1063
+ const projected = await projectMessage(client, ctx, all[next], max, room);
1064
+ const size = jsonBytes(projected) + (messages.length > 0 ? 1 : 0);
1065
+ if (messages.length > 0 && bytes + size > RESULT_BUDGET_BYTES)
1066
+ break;
1067
+ messages.push(projected);
1068
+ bytes += size;
1069
+ next += 1;
1070
+ }
1071
+ const hasMore = next < all.length;
1072
+ return {
1073
+ id: threadId,
1074
+ messageCount: all.length,
1075
+ messages,
1076
+ page: { hasMore, nextCursor: hasMore ? await cursorAfter(ids, next, "get_thread", call) : null },
1077
+ };
1078
+ },
1079
+ },
1080
+ {
1081
+ name: "get_message",
1082
+ description: "Get one Gmail message with headers, its plain-text body (HTML converted only when no text part exists), and attachment metadata. Never downloads attachments.",
1083
+ annotations: readOnly,
1084
+ inputSchema: input({
1085
+ messageId: idProperty("Message id from get_thread, or a draft's messageId."),
1086
+ maxBodyChars: bodyCharsProperty(DEFAULT_MESSAGE_BODY_CHARS),
1087
+ raw: {
1088
+ type: "boolean",
1089
+ description: "Return Gmail's untouched message resource (every header, base64url part bodies) instead of the projection. Refused past connecta's 192 KiB result limit.",
1090
+ },
1091
+ }, ["messageId"]),
1092
+ outputSchema: MESSAGE_SCHEMA,
1093
+ handler: async (args, ctx) => {
1094
+ const message = await client.json({
1095
+ method: "GET",
1096
+ path: `/messages/${encodeURIComponent(args["messageId"])}`,
1097
+ query: { format: "full" },
1098
+ }, ctx);
1099
+ if (args["raw"] !== true) {
1100
+ return await projectMessage(client, ctx, message, bodyChars(args, DEFAULT_MESSAGE_BODY_CHARS));
1101
+ }
1102
+ // Untouched means uncuttable: a raw resource past the result budget
1103
+ // could not reach a program, so it is refused rather than half-sent.
1104
+ const size = jsonBytes(message);
1105
+ if (size > RESULT_BUDGET_BYTES) {
1106
+ throw new ConnectorCallError("invalid_args", `This message's untouched resource is ${size} bytes, past connecta's ${RESULT_BUDGET_BYTES / 1024} KiB result limit. Read it without raw; the projection caps its body and lists its attachments.`);
1107
+ }
1108
+ return message;
1109
+ },
1110
+ },
1111
+ {
1112
+ name: "list_labels",
1113
+ description: "List the user's Gmail labels, system and user-created, with the ids messages carry; no message counts. Search by name with label: in search_threads.",
1114
+ annotations: readOnly,
1115
+ inputSchema: input({
1116
+ cursor: {
1117
+ ...CURSOR_PROPERTY,
1118
+ description: "Opaque page.nextCursor from the previous page. Gmail returns every label at once; connecta pages them so thousands still fit, and answers conflict if the list changed.",
1119
+ },
1120
+ }, []),
1121
+ outputSchema: {
1122
+ type: "object",
1123
+ properties: {
1124
+ labels: {
1125
+ type: "array",
1126
+ items: {
1127
+ type: "object",
1128
+ properties: {
1129
+ id: { type: "string" },
1130
+ name: { type: "string" },
1131
+ type: { type: "string", enum: ["system", "user"] },
1132
+ },
1133
+ },
1134
+ },
1135
+ labelsOmitted: { type: "integer" },
1136
+ page: PAGE_SCHEMA,
1137
+ },
1138
+ required: ["labels", "page"],
1139
+ },
1140
+ handler: async (args, ctx) => {
1141
+ const listing = asRecord(await client.json({ method: "GET", path: "/labels" }, ctx));
1142
+ const call = await callKey("list_labels", {});
1143
+ const claims = openCursor(args["cursor"], "list_labels", call);
1144
+ const all = asArray(listing["labels"]);
1145
+ const ids = all.map((label) => String(asRecord(label)["id"] ?? ""));
1146
+ const start = await resumeAt(ids, claims, "label list");
1147
+ // A mailbox may hold 10,000 labels; Gmail sends them in one answer
1148
+ // with no paging of its own, so connecta pages them, budgeting the
1149
+ // whole result: wrapper and longest cursor first, then rows.
1150
+ const labels = [];
1151
+ // A label whose id is too long to use is left out and counted, never
1152
+ // returned with a cut id that would name nothing.
1153
+ let omitted = 0;
1154
+ let bytes = jsonBytes({ labels: [], labelsOmitted: all.length, page: RESERVED_PAGE });
1155
+ let next = start;
1156
+ while (next < all.length) {
1157
+ const label = asRecord(all[next]);
1158
+ const id = idOf(label["id"], LABEL_ID_BYTES);
1159
+ if (id === undefined) {
1160
+ omitted += 1;
1161
+ next += 1;
1162
+ continue;
1163
+ }
1164
+ const row = compact({
1165
+ id,
1166
+ name: clipped(text(label["name"]), FIELD_BYTES),
1167
+ type: label["type"] === "system" ? "system" : label["type"] === "user" ? "user" : undefined,
1168
+ });
1169
+ const size = jsonBytes(row) + (labels.length > 0 ? 1 : 0);
1170
+ if (labels.length > 0 && bytes + size > RESULT_BUDGET_BYTES)
1171
+ break;
1172
+ labels.push(row);
1173
+ bytes += size;
1174
+ next += 1;
1175
+ }
1176
+ const hasMore = next < all.length;
1177
+ return compact({
1178
+ labels,
1179
+ labelsOmitted: omitted > 0 ? omitted : undefined,
1180
+ page: { hasMore, nextCursor: hasMore ? await cursorAfter(ids, next, "list_labels", call) : null },
1181
+ });
1182
+ },
1183
+ },
1184
+ {
1185
+ name: "list_drafts",
1186
+ description: "List the user's Gmail drafts, newest first, with subject, recipients, and snippet. Use get_draft for a body.",
1187
+ annotations: readOnly,
1188
+ inputSchema: input({ query: QUERY_PROPERTY, limit: LIMIT_PROPERTY, cursor: CURSOR_PROPERTY }, []),
1189
+ outputSchema: {
1190
+ type: "object",
1191
+ properties: {
1192
+ drafts: {
1193
+ type: "array",
1194
+ items: {
1195
+ type: "object",
1196
+ properties: {
1197
+ draftId: { type: "string" },
1198
+ messageId: { type: "string" },
1199
+ threadId: { type: "string" },
1200
+ subject: { type: "string" },
1201
+ to: { type: "string" },
1202
+ updatedAt: { type: "string" },
1203
+ snippet: { type: "string" },
1204
+ omittedIds: OMITTED_IDS_SCHEMA,
1205
+ },
1206
+ },
1207
+ },
1208
+ page: PAGE_SCHEMA,
1209
+ },
1210
+ required: ["drafts", "page"],
1211
+ },
1212
+ handler: async (args, ctx) => {
1213
+ const limit = args["limit"] ?? DEFAULT_PAGE_SIZE;
1214
+ const call = await callKey("list_drafts", { query: args["query"] ?? null, limit });
1215
+ const claims = openCursor(args["cursor"], "list_drafts", call);
1216
+ const listing = asRecord(await client.json({
1217
+ method: "GET",
1218
+ path: "/drafts",
1219
+ query: {
1220
+ q: args["query"],
1221
+ maxResults: limit,
1222
+ pageToken: claims?.p,
1223
+ },
1224
+ }, ctx));
1225
+ const drafts = await mapLimited(asArray(listing["drafts"]).map(asRecord), SUMMARY_CONCURRENCY, (row) => unlessGone(async () => projectDraftSummary(await client.json({
1226
+ method: "GET",
1227
+ path: `/drafts/${encodeURIComponent(String(row["id"]))}`,
1228
+ query: { format: "metadata" },
1229
+ }, ctx))));
1230
+ return { drafts: drafts.filter((draft) => draft !== undefined), page: gmailPage(listing, "list_drafts", call) };
1231
+ },
1232
+ },
1233
+ {
1234
+ name: "get_draft",
1235
+ description: "Get one Gmail draft's headers and plain-text body by draft id. Reading it never sends it.",
1236
+ annotations: readOnly,
1237
+ inputSchema: input({
1238
+ draftId: idProperty("Draft id from list_drafts or create_draft."),
1239
+ maxBodyChars: bodyCharsProperty(DEFAULT_MESSAGE_BODY_CHARS),
1240
+ }, ["draftId"]),
1241
+ outputSchema: {
1242
+ type: "object",
1243
+ properties: { draftId: { type: "string" }, messageId: { type: "string" }, ...DRAFT_MESSAGE_PROPERTIES },
1244
+ required: ["draftId"],
1245
+ },
1246
+ handler: async (args, ctx) => {
1247
+ const draft = asRecord(await client.json({
1248
+ method: "GET",
1249
+ path: `/drafts/${encodeURIComponent(args["draftId"])}`,
1250
+ query: { format: "full" },
1251
+ }, ctx));
1252
+ // The draft was read by the id the caller passed, so that is its id
1253
+ // whatever Gmail echoed back.
1254
+ const draftId = idOf(draft["id"]) ?? args["draftId"];
1255
+ // A draft's message id changes on every update; the draft id does not.
1256
+ // The body is fitted against this finished shape, wrapper and all.
1257
+ return await projectMessage(client, ctx, draft["message"], bodyChars(args, DEFAULT_MESSAGE_BODY_CHARS), RESULT_BUDGET_BYTES,
1258
+ // Omissions name the public field: here the message's `id` is
1259
+ // returned as `messageId`.
1260
+ ({ id, omittedIds, ...message }) => compact({
1261
+ draftId,
1262
+ messageId: id,
1263
+ ...message,
1264
+ omittedIds: Array.isArray(omittedIds)
1265
+ ? omittedIds.map((name) => (name === "id" ? "messageId" : name))
1266
+ : undefined,
1267
+ }));
1268
+ },
1269
+ },
1270
+ {
1271
+ name: "create_draft",
1272
+ 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.",
1273
+ // Additive: a new draft changes nothing that existed. Not read-only, so
1274
+ // program calls require a trusted pool; other writes use
1275
+ // call_destructive_tool. The host controls approval.
1276
+ annotations: { readOnlyHint: false, destructiveHint: false },
1277
+ inputSchema: input({
1278
+ to: ADDRESSES("To"),
1279
+ cc: ADDRESSES("Cc"),
1280
+ bcc: ADDRESSES("Bcc"),
1281
+ subject: SUBJECT_PROPERTY,
1282
+ body: BODY_PROPERTY,
1283
+ htmlBody: HTML_BODY_PROPERTY,
1284
+ replyToMessageId: REPLY_PROPERTY,
1285
+ }, ["body"]),
1286
+ outputSchema: SAVED_DRAFT_SCHEMA,
1287
+ handler: async (args, ctx) => {
1288
+ const draft = await client.json({
1289
+ method: "POST",
1290
+ path: "/drafts",
1291
+ body: await compose(args, ctx, { threadId: undefined, inReplyTo: undefined, references: undefined }),
1292
+ }, ctx);
1293
+ return projectSavedDraft(draft);
1294
+ },
1295
+ },
1296
+ {
1297
+ name: "update_draft",
1298
+ 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.",
1299
+ // Destructive: Gmail replaces the draft's whole message, so the body it
1300
+ // held — possibly typed by the user — is gone. Still a draft, never sent.
1301
+ annotations: { readOnlyHint: false, destructiveHint: true },
1302
+ inputSchema: input({
1303
+ draftId: idProperty("Draft id from list_drafts or create_draft."),
1304
+ to: ADDRESSES("To; omit to keep the current"),
1305
+ cc: ADDRESSES("Cc; omit to keep the current"),
1306
+ bcc: ADDRESSES("Bcc; omit to keep the current"),
1307
+ subject: { ...SUBJECT_PROPERTY, description: "Subject line; omit to keep the current one." },
1308
+ body: { ...BODY_PROPERTY, description: "The new plain-text body, replacing the old one entirely." },
1309
+ htmlBody: {
1310
+ ...HTML_BODY_PROPERTY,
1311
+ description: "Optional HTML alternative; omitting it drops any HTML the draft had.",
1312
+ },
1313
+ replyToMessageId: REPLY_PROPERTY,
1314
+ }, ["draftId", "body"]),
1315
+ outputSchema: SAVED_DRAFT_SCHEMA,
1316
+ handler: async (args, ctx) => {
1317
+ const draftPath = `/drafts/${encodeURIComponent(args["draftId"])}`;
1318
+ // Read the whole draft before replacing it: Gmail's update takes a
1319
+ // whole message, so whatever the rebuilt one leaves out is deleted.
1320
+ // Kept unless restated: From, To, Cc, Bcc, Reply-To, Subject, the
1321
+ // thread, In-Reply-To, and References. Any other header is not.
1322
+ const existing = asRecord(await client.json({ method: "GET", path: draftPath, query: { format: "full" } }, ctx));
1323
+ const message = asRecord(existing["message"]);
1324
+ const payload = asRecord(message["payload"]);
1325
+ const lost = unrebuildable(payload);
1326
+ if (lost.length > 0) {
1327
+ 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.`);
1328
+ }
1329
+ const keep = (name, argument) => args[argument] === undefined
1330
+ ? addressEntries(`The draft's ${name} (pass ${argument} explicitly)`, oneLine(header(payload, name)))
1331
+ : undefined;
1332
+ const references = messageIds(header(payload, "References"));
1333
+ const kept = {
1334
+ threadId: text(message["threadId"]),
1335
+ inReplyTo: messageIds(header(payload, "In-Reply-To"))[0],
1336
+ references: references.length > 0 ? references.join("\r\n ") : undefined,
1337
+ subject: oneLine(header(payload, "Subject")),
1338
+ from: addressEntries("The draft's From", oneLine(header(payload, "From"))),
1339
+ replyTo: addressEntries("The draft's Reply-To", oneLine(header(payload, "Reply-To"))),
1340
+ ...defined({ to: keep("To", "to"), cc: keep("Cc", "cc"), bcc: keep("Bcc", "bcc") }),
1341
+ };
1342
+ const draft = await client.json({ method: "PUT", path: draftPath, body: await compose(args, ctx, kept) }, ctx);
1343
+ return projectSavedDraft(draft);
1344
+ },
1345
+ },
1346
+ ];
1347
+ }
1348
+ // --- Guide ------------------------------------------------------------------------
1349
+ function usageGuide(purpose, instructions) {
1350
+ const extra = instructions?.trim();
1351
+ return `${skill.fragments.guide_0}${purpose}${skill.fragments.guide_1}${extra ? `\n## ${skill.instructionsHeading}\n\n${extra}\n` : ""}`;
1352
+ }
1353
+ // --- Construction -----------------------------------------------------------------
1354
+ /**
1355
+ * A maintained, draft-only Gmail connection acting as each signed-in
1356
+ * Workspace user through a delegated service account.
1357
+ *
1358
+ * Setup, once per Workspace (shared with every Workspace provider):
1359
+ *
1360
+ * 1. In a Google Cloud project, enable the **Gmail API**.
1361
+ * 2. Create a service account. Grant it no IAM roles: delegation, not project
1362
+ * permission, is what reaches mailboxes.
1363
+ * 3. Create a JSON key for it. If key creation is refused, the organization
1364
+ * policy `iam.disableServiceAccountKeyCreation` is enforced; an org policy
1365
+ * administrator can override it for this project alone.
1366
+ * 4. Copy the service account's numeric **client ID** (the key's `client_id`).
1367
+ * 5. As a Workspace super admin: Admin console → Security → Access and data
1368
+ * control → API controls → Manage Domain Wide Delegation → Add new. Paste
1369
+ * the client ID and exactly these scopes, comma-separated
1370
+ * ({@link GMAIL_SCOPES}):
1371
+ * `https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/gmail.compose`.
1372
+ * A new grant can take up to 24 hours to apply.
1373
+ * 6. Configure inbound auth: a `subject` function is never asked about an
1374
+ * open deployment's anonymous requests, which fail `auth_required`.
1375
+ * Then give the deployment the key as a secret and map callers to mailboxes:
1376
+ *
1377
+ * ```ts
1378
+ * gmail("mail", {
1379
+ * purpose: "Staff email triage and reply drafting",
1380
+ * serviceAccount: env.GOOGLE_SERVICE_ACCOUNT_JSON,
1381
+ * subject: (identity) => mailboxes[identity.principal?.id ?? ""],
1382
+ * });
1383
+ * ```
1384
+ *
1385
+ * `gmail.compose` technically allows sending; this connection never does,
1386
+ * because no tool reaches a send method. Drafts are additive writes the host
1387
+ * approves through call_destructive_tool or a trusted program.
1388
+ */
1389
+ export const gmail = asProviderFactory({
1390
+ name: "gmail",
1391
+ title: "Gmail",
1392
+ kind: "api",
1393
+ readme: "Gmail",
1394
+ bundle: {
1395
+ "baselineGzip": 27085,
1396
+ "maxGzip": 87085,
1397
+ "note": "./providers/gmail starts at 27,085 B gzip (#678): a hand-written api() surface over the guarded transport plus the shared Google Workspace delegation layer — Web Crypto RS256 signing, an in-memory token cache, result-size budgeting, bound cursors, and request-outcome tracking, with no OAuth client and no MCP SDK. The other Workspace products share that layer and should land in the same class. The cap uses the existing baseline + 60,000 B policy.",
1398
+ },
1399
+ skill,
1400
+ options: GOOGLE_WORKSPACE_OPTIONS,
1401
+ create: gmailConnector,
1402
+ });
1403
+ function gmailConnector(id, options) {
1404
+ const connection = workspaceConnection("gmail", options);
1405
+ const client = googleWorkspaceClient({
1406
+ provider: "Gmail",
1407
+ api: "Gmail API",
1408
+ baseUrl: options.baseUrl?.trim() || GMAIL_API_BASE_URL,
1409
+ scopes: GMAIL_SCOPES,
1410
+ maxResponseBytes: GMAIL_MAX_RESPONSE_BYTES,
1411
+ // Every request is confined to the token's own mailbox, so a 404 cannot
1412
+ // be another account's message hiding behind a permission gap.
1413
+ notFound: "absent",
1414
+ connection,
1415
+ });
1416
+ return api(id, {
1417
+ ...defined({
1418
+ callAdmission: options.callAdmission,
1419
+ maxResultBytes: options.maxResultBytes,
1420
+ }),
1421
+ title: options.title ?? "Gmail (drafts only)",
1422
+ description: `Gmail as the signed-in Workspace user: read mail and write drafts, never send — ${connection.purpose}`,
1423
+ usageGuide: {
1424
+ content: usageGuide(connection.purpose, options.instructions),
1425
+ summary: "Each caller's own mailbox: Gmail search syntax, capped bodies, reply threading, drafts that never send.",
1426
+ // Required: whose mailbox it is, and that update_draft replaces the
1427
+ // body, are conventions no schema can carry.
1428
+ required: true,
1429
+ },
1430
+ // Every tool's result passes the whole-size postcondition on its way out.
1431
+ tools: tools(client).map((tool) => ({
1432
+ ...tool,
1433
+ handler: async (args, ctx) => deliverable(tool.name, await tool.handler(args, ctx)),
1434
+ })),
1435
+ });
1436
+ }