{"package":"@executor.sh/gmail","files":[{"path":"README.md","content":"# Gmail\n\nMessages, threads, drafts, labels, history, filters, forwarding addresses, send-as aliases, delegates and settings for a Gmail account, as 79 tools generated from the Gmail API. Every mutation asks for approval.\n\n## Curated tools\n\nThe top-level tools are shaped for agents, after [gogcli](https://github.com/openclaw/gogcli)'s typed tool set: `search` (Gmail search syntax, returning thread summaries with sender, subject, date and snippet), `readThread`, `readMessage` and `attachment` (decoded text instead of MIME), `labels` (with unread counts), `send` and `draft` (plain fields; replies keep the thread, subject and references, with reply-all), and `modify` (archive, read, star, trash and labels by name). Reads span every linked account at once unless `account` names one; writes need `account` only when several accounts are linked, and ask for approval. Results name the account they came from by its address, read from the Gmail profile and cached for a day. Their code is `tools/`, copied from `shared/google-tools` by `node scripts/google-vendor.ts`, which also gives the same tools to the all-in-one `google` package. The generated tools described below are under `api`, each taking an `accountId`.\n\n## Interface\n\nOpenAPI, from `https://api.apis.guru/v2/specs/googleapis.com/gmail/v1/openapi.json`. Google publishes the Gmail API only as a Discovery document, which is the authoritative source (`https://gmail.googleapis.com/$discovery/rest?version=v1`), not OpenAPI. The spec used here is APIs.guru's maintained, unofficial OpenAPI conversion of that document (79 operations, matching the Discovery document). The spec's implicit/authorization-code oauth2 schemes are replaced by bearer schemes of the same names so the OAuth access token is sent as `Authorization: Bearer`.\n\nGoogle also runs an official Gmail MCP server (`https://gmailmcp.googleapis.com/mcp/v1`). It is a gated Developer Preview: it requires Workspace Developer Preview Program membership and enabling `gmailmcp.googleapis.com`, exposes only about ten tools (search threads, get thread/message, drafts, labels), cannot send email, and allows only `gmail.readonly` and `gmail.compose`. Because of that, the REST API was chosen deliberately over the MCP server. Revisit once the MCP reaches general availability.\n\n## Auth\n\nOAuth 2.0 only; Gmail does not accept API keys for mailbox data. Create an OAuth client in the Google Cloud console (Google Auth Platform > Clients, type \"Web application\"), enable the Gmail API for the project, and add the Executor redirect URI (shown on the connect page, `/api/oauth/callback` on the Executor origin) as an authorized redirect URI. Add the test users on the consent screen while the app is in testing. The connect page asks for the client ID and secret.\n\nScopes requested: `https://mail.google.com/` (full Gmail access, the scope Executor for Google is verified for; `gmail.modify` is not on its consent screen and makes Google show the unverified-app warning) and `gmail.settings.basic` (filters, signatures, vacation responder, send-as). Both are RESTRICTED scopes in Google's classification. Production use outside the project's test users needs restricted-scope OAuth verification plus an annual third-party security assessment. `gmail.settings.basic` is kept because the filter, signature, vacation and send-as tools are worth it; drop it from `index.ts` to reduce the verification burden to `https://mail.google.com/` alone (those settings tools then fail). Sign-in uses `access_type=offline` and `prompt=consent` so Google returns a refresh token.\n\n## Known gaps\n\n10 of the 79 tools are exposed but cannot succeed with the granted scopes:\n\n- 3 need the restricted `https://mail.google.com/` scope (permanent delete): `messages.delete`, `messages.batchDelete`, `threads.delete`. Trash instead.\n- 7 are administrative `gmail.settings.sharing` or service-account-only operations that need domain-wide delegation: `delegates.list/get/create/delete` (4), `forwardingAddresses.create/delete` (2), `settings.updateAutoForwarding` (1).\n- The `settings.cse.*` tools only work on Workspace accounts with client-side encryption.\n- Media upload endpoints (`/upload/gmail/v1/...`) are not part of the converted spec; send and insert use the `raw` base64url message field.\n"},{"path":"icon.ts","content":"import type { AppIcon } from \"apps\";\n\n// Generated by scripts/icon.ts from the vendor's logo.\nexport const icons: readonly AppIcon[] = [\n  {\n    \"src\": \"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDE5MiAxOTIiIGhlaWdodD0iMTkyIiB2aWV3Qm94PSIwIDAgMTkyIDE5MiIgd2lkdGg9IjE5MiI+PHJlY3QgZmlsbD0ibm9uZSIgaGVpZ2h0PSIxOTIiIHdpZHRoPSIxOTIiLz48Zz48Zz48cGF0aCBkPSJNOCw0NnYxNmwxOC4zNSwxNy43Nkw0OCw5Mmw0LTI2LjkzTDQ4LDQwbC0xMS4yLTguNEMyNC45MywyMi43LDgsMzEuMTcsOCw0NiIgZmlsbD0iI0M1MjIxRiIvPjxwYXRoIGQ9Ik0xNDQsNDBsLTQsMjUuNGw0LDI2LjZsMTkuNjUtOS43M0wxODQsNjJWNDZjMC0xNC44My0xNi45My0yMy4zLTI4LjgtMTQuNEwxNDQsNDB6IiBmaWxsPSIjRkJCQzA0Ii8+PHBhdGggZD0iTTIwLDE2MGgyOFY5Mkw4LDYydjg2QzgsMTU0LjYzLDEzLjM3LDE2MCwyMCwxNjAiIGZpbGw9IiM0Mjg1RjQiLz48cGF0aCBkPSJNMTQ0LDE2MGgyOGM2LjYzLDAsMTItNS4zNywxMi0xMlY2MmwtNDAsMzBWMTYweiIgZmlsbD0iIzM0QTg1MyIvPjxwb2x5Z29uIGZpbGw9IiNFQTQzMzUiIHBvaW50cz0iOTYsNzYgNDgsNDAgNDgsOTIgOTYsMTI4IDE0NCw5MiAxNDQsNDAiLz48L2c+PC9nPjwvc3ZnPg==\",\n    \"mimeType\": \"image/svg+xml\"\n  }\n];\n"},{"path":"index.ts","content":"import {\n  accountRouter,\n  decodeJson,\n  defineApp,\n  defineProvider,\n  oauth2,\n  object,\n  router,\n  string,\n  withApprovals,\n} from \"apps\";\nimport { always } from \"apps/operations/approval\";\nimport { liveOpenapiRouter } from \"apps/openapi\";\nimport { icons } from \"./icon.js\";\nimport { addresses } from \"./tools/shared.js\";\nimport { gmail as gmailTools } from \"./tools/gmail.js\";\nimport { options } from \"./openapi.js\";\n\nconst Profile = object({ emailAddress: string() });\n\nconst gmail = defineProvider({\n  name: \"Gmail\",\n  hosts: [\"gmail.googleapis.com\"],\n  auth: {\n    // Google offers no client registration: the connect page asks for an OAuth client.\n    oauth: oauth2({\n      discover: \"https://accounts.google.com\",\n      scopes: [\n        \"https://mail.google.com/\",\n        \"https://www.googleapis.com/auth/gmail.settings.basic\",\n      ],\n      // Google issues refresh tokens only when asked for offline access.\n      authorizationParams: { access_type: \"offline\", prompt: \"consent\" },\n    }),\n  },\n  async health({ account, fetch, signal }) {\n    const response = await fetch(\"https://gmail.googleapis.com/gmail/v1/users/me/profile\", {\n      signal,\n      headers: { Authorization: `Bearer ${account.fields.access_token}` },\n    });\n    const me = await decodeJson(response, Profile);\n    return { accountInfo: { externalId: me.emailAddress, email: me.emailAddress } };\n  },\n});\n\nexport default defineApp(\n  { user: { accounts: { gmail: gmail.many() } }, icons },\n  async ({ user, cache, fetch, signal }) => {\n    const curated = gmailTools({ accounts: user?.accounts.gmail ?? [], address: addresses.gmail, api: () => \"api\" });\n    return {\n      tools: router(\n        {\n          ...curated.tools,\n          api: router(\n            await accountRouter(\n              user?.accounts.gmail ?? [],\n              (account) =>\n                withApprovals(liveOpenapiRouter({ ...options, cache, fetch, signal, account }), (tool) =>\n                  tool.kind === \"mutation\" ? always() : undefined,\n                ),\n              { signal },\n            ),\n            {\n              title: \"Gmail API\",\n              description: \"The complete Gmail API, per account: every tool takes accountId and the API's own input.\",\n            },\n          ),\n        },\n        {\n          instructions: curated.meta.instructions,\n          title: \"Gmail\",\n          description:\n            \"Read, search, send and organize Gmail messages, threads, drafts, labels, filters and settings.\",\n          icons,\n        },\n      ),\n    };\n  },\n);\n"},{"path":"openapi.ts","content":"import type { OpenapiToolNamesOptions } from \"apps/openapi\";\n\n// Google publishes the Gmail API as a Discovery document, not OpenAPI. This is APIs.guru's\n// maintained OpenAPI conversion of that document (unofficial). Its oauth2 schemes are\n// implicit-flow descriptions, so bearer schemes of the same names carry the access token instead.\nexport const options = {\n  source: { url: \"https://api.apis.guru/v2/specs/googleapis.com/gmail/v1/openapi.json\" },\n  allowedOrigin: \"https://gmail.googleapis.com\",\n  securitySchemes: {\n    Oauth2c: { type: \"http\", scheme: \"bearer\" },\n    Oauth2: { type: \"http\", scheme: \"bearer\" },\n  },\n  methods: {\n    oauth: [\n      { scheme: \"Oauth2c\", field: \"access_token\", part: \"value\", prefix: \"\" },\n      { scheme: \"Oauth2\", field: \"access_token\", part: \"value\", prefix: \"\" },\n    ],\n  },\n  oauth: [],\n} satisfies OpenapiToolNamesOptions;\n"},{"path":"package.json","content":"{\n  \"name\": \"@executor.sh/gmail\",\n  \"description\": \"Read, search, send and organize Gmail messages, threads, drafts, labels, filters and settings through the Gmail API.\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"executor\": {\n    \"title\": \"Gmail\"\n  },\n  \"dependencies\": {\n    \"apps\": \"0.0.1-beta.100\"\n  }\n}\n"},{"path":"tools/gmail.ts","content":"// Generated by scripts/google-vendor.ts from shared/google-tools; edit the original and rerun it.\n// Gmail tools shaped for agents: search returns readable thread summaries across every linked\n// account, reads return decoded text, and writes take plain fields instead of raw MIME.\nimport { array, boolean, mutation, number, object, query, string } from \"apps\";\nimport { always } from \"apps/operations/approval\";\nimport {\n  base64Lines,\n  base64url,\n  decodeBody,\n  encodeHeader,\n  fanOut,\n  google,\n  htmlToText,\n  mapLimit,\n  select,\n  single,\n  type Base,\n  type Context,\n  type Source,\n  type Target,\n  type WriteContext,\n} from \"./shared.js\";\n\nconst base = \"https://gmail.googleapis.com/gmail/v1/users/me\";\nconst account = string().optional();\n\ninterface Part {\n  readonly mimeType?: string;\n  readonly filename?: string;\n  readonly headers?: readonly { readonly name: string; readonly value: string }[];\n  readonly body?: { readonly data?: string; readonly size?: number; readonly attachmentId?: string };\n  readonly parts?: readonly Part[];\n}\ninterface Message {\n  readonly id: string;\n  readonly threadId: string;\n  readonly labelIds?: readonly string[];\n  readonly snippet?: string;\n  readonly internalDate?: string;\n  readonly payload?: Part;\n}\n\nconst header = (part: Part | undefined, name: string) =>\n  part?.headers?.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;\n\nconst charset = (part: Part) => /charset=\"?([^\";]+)\"?/i.exec(header(part, \"Content-Type\") ?? \"\")?.[1];\n\n/** The message text: its text/plain part, else its HTML part as text. Attachments are listed, not read. */\nconst content = (payload: Part | undefined) => {\n  let plain: string | undefined;\n  let html: string | undefined;\n  const attachments: { filename: string; mimeType: string; size: number; attachmentId: string }[] = [];\n  const walk = (part: Part) => {\n    if (part.filename && part.body?.attachmentId) {\n      attachments.push({\n        filename: part.filename,\n        mimeType: part.mimeType ?? \"application/octet-stream\",\n        size: part.body.size ?? 0,\n        attachmentId: part.body.attachmentId,\n      });\n      return;\n    }\n    if (part.body?.data && part.mimeType === \"text/plain\" && plain === undefined) plain = decodeBody(part.body.data, charset(part));\n    if (part.body?.data && part.mimeType === \"text/html\" && html === undefined) html = decodeBody(part.body.data, charset(part));\n    for (const child of part.parts ?? []) walk(child);\n  };\n  if (payload) walk(payload);\n  return { body: (plain ?? (html === undefined ? \"\" : htmlToText(html))).trim(), attachments };\n};\n\n/** Label IDs to names; system labels keep their IDs (INBOX, UNREAD, STARRED...). */\nconst labelNames = async (ctx: Base, target: Target) => {\n  const { labels = [] } = await google(ctx, target, `${base}/labels`);\n  return new Map<string, string>((labels as { id: string; name: string }[]).map((label) => [label.id, label.name]));\n};\n\nconst summary = (target: Target, message: Message, names: Map<string, string>) => ({\n  account: target.email,\n  messageId: message.id,\n  threadId: message.threadId,\n  from: header(message.payload, \"From\") ?? \"\",\n  to: header(message.payload, \"To\") ?? \"\",\n  subject: header(message.payload, \"Subject\") ?? \"\",\n  date: message.internalDate ? new Date(Number(message.internalDate)).toISOString() : \"\",\n  snippet: message.snippet ?? \"\",\n  unread: message.labelIds?.includes(\"UNREAD\") ?? false,\n  labels: (message.labelIds ?? []).filter((id) => id !== \"UNREAD\").map((id) => names.get(id) ?? id),\n});\n\nconst full = (target: Target, message: Message, names: Map<string, string>) => ({\n  ...summary(target, message, names),\n  cc: header(message.payload, \"Cc\") ?? \"\",\n  replyTo: header(message.payload, \"Reply-To\") ?? \"\",\n  ...content(message.payload),\n});\n\n/** Resolves label names or IDs, case-insensitively, to IDs. */\nconst labelIds = (names: Map<string, string>, wanted: readonly string[]) => {\n  const byName = new Map([...names].map(([id, name]) => [name.toLowerCase(), id]));\n  return wanted.map((label) => {\n    const id = names.has(label) ? label : byName.get(label.toLowerCase());\n    if (id === undefined) throw new Error(`No Gmail label named ${label}. Create it first, or list labels with gmail.labels.`);\n    return id;\n  });\n};\n\nconst Compose = {\n  account,\n  to: array(string()).optional(),\n  cc: array(string()).optional(),\n  bcc: array(string()).optional(),\n  subject: string().optional(),\n  body: string(),\n  html: boolean().optional(),\n  replyToMessageId: string().optional(),\n  replyAll: boolean().optional(),\n  from: string().optional(),\n};\ntype ComposeInput = {\n  readonly account?: string;\n  readonly to?: readonly string[];\n  readonly cc?: readonly string[];\n  readonly bcc?: readonly string[];\n  readonly subject?: string;\n  readonly body: string;\n  readonly html?: boolean;\n  readonly replyToMessageId?: string;\n  readonly replyAll?: boolean;\n  readonly from?: string;\n};\n\nconst addresses = (value: string | undefined) =>\n  (value ?? \"\")\n    .split(/,(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/)\n    .map((item) => item.trim())\n    .filter(Boolean);\nconst bare = (address: string) => (/<([^>]+)>/.exec(address)?.[1] ?? address).trim().toLowerCase();\n\n/**\n * Builds the RFC 2822 message. A reply takes the original's thread, subject and references, and\n * addresses its sender (and, with replyAll, everyone else on it except this account).\n */\nconst compose = async (ctx: Base, target: Target, input: ComposeInput) => {\n  let to = [...(input.to ?? [])];\n  let cc = [...(input.cc ?? [])];\n  let subject = input.subject;\n  let threadId: string | undefined;\n  const extra: string[] = [];\n  if (input.replyToMessageId) {\n    const original: Message = await google(ctx, target, `${base}/messages/${encodeURIComponent(input.replyToMessageId)}`, {\n      search: { format: \"metadata\", metadataHeaders: [\"From\", \"To\", \"Cc\", \"Subject\", \"Message-ID\", \"References\", \"Reply-To\"] },\n    });\n    threadId = original.threadId;\n    const messageId = header(original.payload, \"Message-ID\");\n    if (messageId) {\n      extra.push(`In-Reply-To: ${messageId}`);\n      extra.push(`References: ${[header(original.payload, \"References\"), messageId].filter(Boolean).join(\" \")}`);\n    }\n    const originalSubject = header(original.payload, \"Subject\") ?? \"\";\n    subject ??= /^re:/i.test(originalSubject) ? originalSubject : `Re: ${originalSubject}`;\n    const self = target.email.toLowerCase();\n    const sender = header(original.payload, \"Reply-To\") ?? header(original.payload, \"From\") ?? \"\";\n    // Replying to your own message goes back to its recipients.\n    const fromSelf = bare(header(original.payload, \"From\") ?? \"\") === self;\n    if (to.length === 0) to = fromSelf ? addresses(header(original.payload, \"To\")) : addresses(sender);\n    if (input.replyAll) {\n      const already = new Set([...to, ...cc].map(bare));\n      for (const address of [...addresses(header(original.payload, \"To\")), ...addresses(header(original.payload, \"Cc\"))]) {\n        if (bare(address) === self || already.has(bare(address))) continue;\n        already.add(bare(address));\n        cc.push(address);\n      }\n    }\n  }\n  const lines = [\n    ...(input.from ? [`From: ${input.from}`] : []),\n    ...(to.length ? [`To: ${to.join(\", \")}`] : []),\n    ...(cc.length ? [`Cc: ${cc.join(\", \")}`] : []),\n    ...(input.bcc?.length ? [`Bcc: ${input.bcc.join(\", \")}`] : []),\n    `Subject: ${encodeHeader(subject ?? \"\")}`,\n    ...extra,\n    \"MIME-Version: 1.0\",\n    `Content-Type: ${input.html ? \"text/html\" : \"text/plain\"}; charset=\"UTF-8\"`,\n    \"Content-Transfer-Encoding: base64\",\n    \"\",\n    base64Lines(input.body),\n  ];\n  return { raw: base64url(lines.join(\"\\r\\n\")), threadId, to, cc, subject: subject ?? \"\" };\n};\n\n/** Matching threads across targets, newest first, each summarised by its latest message. */\nexport const searchMail = async (ctx: Base, targets: readonly Target[], q: string, max: number) => {\n  const { items, errors } = await fanOut(targets, async (target) => {\n    const [{ threads = [] }, names] = await Promise.all([\n      google(ctx, target, `${base}/threads`, { search: { q, maxResults: max } }),\n      labelNames(ctx, target),\n    ]);\n    return mapLimit(threads as { id: string }[], 10, async ({ id }) => {\n      const thread: { messages?: Message[] } = await google(ctx, target, `${base}/threads/${id}`, {\n        search: { format: \"metadata\", metadataHeaders: [\"From\", \"To\", \"Subject\", \"Date\"] },\n      });\n      const messages = thread.messages ?? [];\n      const latest = messages.at(-1)!;\n      return {\n        ...summary(target, latest, names),\n        messageCount: messages.length,\n        unread: messages.some((message) => message.labelIds?.includes(\"UNREAD\")),\n      };\n    });\n  });\n  items.sort((a, b) => b.date.localeCompare(a.date));\n  return { threads: items.slice(0, max), errors };\n};\n\n/** The curated tools and their router's metadata; `source` names the package's accounts. */\nexport const gmail = (source: Source) => ({\n  tools: {\n    search: query(\n      {\n        description:\n          \"Search mail with Gmail search syntax (from:, to:, subject:, is:unread, has:attachment, newer_than:7d, label:, in:inbox...) across every linked Google account, or one. Returns matching threads, newest first, each with its latest message's sender, subject, date and snippet. Read one with readThread.\",\n        input: object({\n          query: string(),\n          account,\n          max: number().optional(),\n        }),\n      },\n      async (ctx: Context, input) =>\n        searchMail(ctx, await select(ctx, source, input.account), input.query, Math.min(Math.max(input.max ?? 20, 1), 100)),\n    ),\n    readThread: query(\n      {\n        description: \"Read a whole thread as decoded text: every message's headers, body and attachment list, oldest first.\",\n        input: object({ account, threadId: string({ minLength: 1 }) }),\n      },\n      async (ctx: Context, input) => {\n        const target = await single(ctx, source, input.account);\n        const [thread, names] = await Promise.all([\n          google(ctx, target, `${base}/threads/${encodeURIComponent(input.threadId)}`, { search: { format: \"full\" } }),\n          labelNames(ctx, target),\n        ]);\n        return { account: target.email, threadId: input.threadId, messages: ((thread.messages ?? []) as Message[]).map((m) => full(target, m, names)) };\n      },\n    ),\n    readMessage: query(\n      {\n        description: \"Read one message as decoded text: headers, body (HTML converted to text) and attachment list.\",\n        input: object({ account, messageId: string({ minLength: 1 }) }),\n      },\n      async (ctx: Context, input) => {\n        const target = await single(ctx, source, input.account);\n        const [message, names] = await Promise.all([\n          google(ctx, target, `${base}/messages/${encodeURIComponent(input.messageId)}`, { search: { format: \"full\" } }),\n          labelNames(ctx, target),\n        ]);\n        return full(target, message, names);\n      },\n    ),\n    labels: query(\n      {\n        description: \"List each account's labels with their unread and total counts.\",\n        input: object({ account }),\n      },\n      async (ctx: Context, input) =>\n        fanOut(await select(ctx, source, input.account), async (target) => {\n          const { labels = [] } = await google(ctx, target, `${base}/labels`);\n          return mapLimit(labels as { id: string; name: string; type: string }[], 10, async (label) => {\n            const detail = await google(ctx, target, `${base}/labels/${encodeURIComponent(label.id)}`);\n            return {\n              account: target.email,\n              id: label.id,\n              name: label.name,\n              type: label.type,\n              unreadThreads: detail.threadsUnread ?? 0,\n              threads: detail.threadsTotal ?? 0,\n            };\n          });\n        }).then(({ items, errors }) => ({ labels: items, errors })),\n    ),\n    send: mutation(\n      {\n        description:\n          \"Send an email. For a reply, pass replyToMessageId: it stays in the thread, gets a Re: subject and goes to the original sender unless `to` is given; replyAll also copies the other recipients. `from` must be one of the account's send-as aliases.\",\n        input: object(Compose),\n        approval: always(),\n      },\n      async (ctx: WriteContext, input) => {\n        const target = await single(ctx, source, input.account);\n        const message = await compose(ctx, target, input);\n        if (message.to.length === 0 && message.cc.length === 0 && !input.bcc?.length) throw new Error(\"Pass at least one recipient.\");\n        const sent = await google(ctx, target, `${base}/messages/send`, {\n          method: \"POST\",\n          body: { raw: message.raw, ...(message.threadId ? { threadId: message.threadId } : {}) },\n        });\n        return { account: target.email, messageId: sent.id, threadId: sent.threadId, to: message.to, cc: message.cc, subject: message.subject };\n      },\n    ),\n    draft: mutation(\n      {\n        description: \"Save a draft without sending it. Takes the same fields as send, and recipients may be left out.\",\n        input: object(Compose),\n        approval: always(),\n      },\n      async (ctx: WriteContext, input) => {\n        const target = await single(ctx, source, input.account);\n        const message = await compose(ctx, target, input);\n        const draft = await google(ctx, target, `${base}/drafts`, {\n          method: \"POST\",\n          body: { message: { raw: message.raw, ...(message.threadId ? { threadId: message.threadId } : {}) } },\n        });\n        return { account: target.email, draftId: draft.id, messageId: draft.message?.id, threadId: draft.message?.threadId };\n      },\n    ),\n    modify: mutation(\n      {\n        description:\n          \"Change threads: archive (leave the inbox), mark read or unread, star or unstar, move to trash, and add or remove labels by name. Applies to every message in each thread.\",\n        input: object({\n          account,\n          threadIds: array(string({ minLength: 1 })),\n          archive: boolean().optional(),\n          read: boolean().optional(),\n          starred: boolean().optional(),\n          trash: boolean().optional(),\n          addLabels: array(string()).optional(),\n          removeLabels: array(string()).optional(),\n        }),\n        approval: always(),\n      },\n      async (ctx: WriteContext, input) => {\n        const target = await single(ctx, source, input.account);\n        const names = await labelNames(ctx, target);\n        const add = labelIds(names, input.addLabels ?? []);\n        const remove = labelIds(names, input.removeLabels ?? []);\n        if (input.archive) remove.push(\"INBOX\");\n        if (input.read === true) remove.push(\"UNREAD\");\n        if (input.read === false) add.push(\"UNREAD\");\n        if (input.starred === true) add.push(\"STARRED\");\n        if (input.starred === false) remove.push(\"STARRED\");\n        await mapLimit(input.threadIds, 10, async (id) => {\n          const thread = encodeURIComponent(id);\n          if (input.trash) await google(ctx, target, `${base}/threads/${thread}/trash`, { method: \"POST\" });\n          if (add.length || remove.length)\n            await google(ctx, target, `${base}/threads/${thread}/modify`, {\n              method: \"POST\",\n              body: { addLabelIds: add, removeLabelIds: remove },\n            });\n        });\n        return { account: target.email, threads: input.threadIds.length, added: add, removed: remove, trashed: input.trash ?? false };\n      },\n    ),\n    attachment: query(\n      {\n        description:\n          \"Read an attachment listed by readMessage or readThread. Text attachments (text/*, JSON, CSV, XML) come back as text; others as base64.\",\n        input: object({ account, messageId: string({ minLength: 1 }), attachmentId: string({ minLength: 1 }), mimeType: string().optional() }),\n      },\n      async (ctx: Context, input) => {\n        const target = await single(ctx, source, input.account);\n        const { data = \"\", size } = await google(\n          ctx,\n          target,\n          `${base}/messages/${encodeURIComponent(input.messageId)}/attachments/${encodeURIComponent(input.attachmentId)}`,\n        );\n        const text = /^(text\\/|application\\/(json|xml|csv))/.test(input.mimeType ?? \"\");\n        return text ? { size, text: decodeBody(data) } : { size, base64: (data as string).replaceAll(\"-\", \"+\").replaceAll(\"_\", \"/\") };\n      },\n    ),\n  },\n  meta: {\n    title: \"Gmail\",\n    description: \"Search, read and send mail across every linked Google account.\",\n    instructions: `search spans every linked account unless \\`account\\` (an address) is given; its results carry the account, and reads and writes take that account. Writes need \\`account\\` only when several accounts are linked. The full Gmail API is under ${source.api(\"gmail\")}.`,\n  },\n});\n"},{"path":"tools/shared.ts","content":"// Generated by scripts/google-vendor.ts from shared/google-tools; edit the original and rerun it.\n// Helpers for the curated tools: choosing accounts, calling Google with one of them, running a read\n// across every linked account, and the encodings Gmail and Calendar need.\nimport { decodeJson, json, object, string, type MutationContext, type QueryContext } from \"apps\";\n\nexport type Context = QueryContext;\nexport type WriteContext = MutationContext;\n/** What the helpers use of a query or mutation context. */\nexport type Base = Pick<Context, \"fetch\" | \"signal\" | \"cache\">;\n/** A linked Google account, as any of the Google packages' slots provides it. */\nexport interface Account {\n  readonly id: string;\n  readonly fields: { readonly access_token: string };\n}\n\n/** How a package reads an account's address with the scopes it already has. */\nexport type Address = (load: { readonly fetch: typeof fetch; readonly signal: AbortSignal }, account: Account) => Promise<string>;\n\n/** The accounts a package's curated tools run with, and how it names them. */\nexport interface Source {\n  readonly accounts: readonly Account[];\n  readonly address: Address;\n  /** Where the package mounts a product's complete API (`gmail` → `api.gmail`), named in the tools' instructions. */\n  readonly api: (product: string) => string;\n}\n\nconst read =\n  <T>(url: string, schema: Parameters<typeof decodeJson<T>>[1], pick: (body: T) => string): Address =>\n  async ({ fetch, signal }, account) =>\n    pick(await decodeJson(await fetch(url, { signal, headers: { Authorization: `Bearer ${account.fields.access_token}` } }), schema));\n\n/** Address lookups for each package's scopes. */\nexport const addresses = {\n  /** Needs the `email` scope; `url` is the userinfo endpoint on a host the provider allows. */\n  userinfo: (url: string) => read(url, object({ email: string() }), (body) => body.email),\n  gmail: read(\"https://gmail.googleapis.com/gmail/v1/users/me/profile\", object({ emailAddress: string() }), (body) => body.emailAddress),\n  /** The primary calendar's ID is the account's address. */\n  calendar: read(\"https://www.googleapis.com/calendar/v3/calendars/primary\", object({ id: string() }), (body) => body.id),\n  drive: read(\n    \"https://www.googleapis.com/drive/v3/about?fields=user(emailAddress)\",\n    object({ user: object({ emailAddress: string() }) }),\n    (body) => body.user.emailAddress,\n  ),\n  /** For scopes that reveal no address, such as Tasks': accounts are named by their ID. */\n  id: (async (_load, account) => account.id) as Address,\n};\n\n/** One linked account with the address it signs in as. */\nexport interface Target {\n  readonly account: Account;\n  readonly email: string;\n}\n\n/** The account's address, cached per account so a fan-out costs one request per account at most once a day. */\nconst emailOf = (ctx: Base, source: Source, account: Account) =>\n  ctx.cache.forAccount(account).get({\n    key: \"email\",\n    schema: string(),\n    freshFor: \"1 day\",\n    load: (load) => source.address(load, account),\n  });\n\n/** Every linked account with its address. */\nexport const linked = (ctx: Base, source: Source): Promise<Target[]> =>\n  Promise.all(source.accounts.map(async (account) => ({ account, email: await emailOf(ctx, source, account) })));\n\n/** The accounts a call names: one by address or account ID, or all of them when `account` is omitted. */\nexport const select = async (ctx: Base, source: Source, account: string | undefined): Promise<Target[]> => {\n  const all = await linked(ctx, source);\n  if (all.length === 0) throw new Error(\"No Google account is linked to this app. Connect one first.\");\n  if (account === undefined) return all;\n  const wanted = account.trim().toLowerCase();\n  const match = all.filter((target) => target.email.toLowerCase() === wanted || target.account.id === account);\n  if (match.length === 0)\n    throw new Error(`No linked Google account matches ${account}. Linked accounts: ${all.map((t) => t.email).join(\", \")}.`);\n  return match;\n};\n\n/** The one account a write uses: the named one, or the only linked account. */\nexport const single = async (ctx: Base, source: Source, account: string | undefined): Promise<Target> => {\n  const targets = await select(ctx, source, account);\n  if (targets.length > 1)\n    throw new Error(`Several Google accounts are linked; pass account as one of: ${targets.map((t) => t.email).join(\", \")}.`);\n  return targets[0]!;\n};\n\n/**\n * Runs a read on each target at once. One account's failure does not hide the others' results: it\n * is reported under `errors` with the account's address.\n */\nexport const fanOut = async <T>(\n  targets: readonly Target[],\n  run: (target: Target) => Promise<readonly T[]>,\n): Promise<{ readonly items: T[]; readonly errors: { readonly account: string; readonly message: string }[] }> => {\n  const settled = await Promise.allSettled(targets.map(run));\n  const items: T[] = [];\n  const errors: { account: string; message: string }[] = [];\n  settled.forEach((result, index) => {\n    if (result.status === \"fulfilled\") items.push(...result.value);\n    else errors.push({ account: targets[index]!.email, message: result.reason instanceof Error ? result.reason.message : String(result.reason) });\n  });\n  return { items, errors };\n};\n\n/** Drops undefined fields, which tool results cannot carry: results must be plain JSON. */\nexport const defined = <T extends object>(value: T): T =>\n  Object.fromEntries(Object.entries(value).filter(([, field]) => field !== undefined)) as T;\n\n/** Calls a Google JSON API with the target's token. `search` values left undefined are omitted. */\nexport const google = async (\n  ctx: Base,\n  target: Target,\n  url: string,\n  init: {\n    readonly method?: string;\n    readonly body?: unknown;\n    readonly search?: Readonly<Record<string, string | number | boolean | readonly string[] | undefined>>;\n  } = {},\n): Promise<any> => {\n  const address = new URL(url);\n  for (const [key, value] of Object.entries(init.search ?? {})) {\n    if (value === undefined) continue;\n    for (const item of Array.isArray(value) ? value : [value]) address.searchParams.append(key, String(item));\n  }\n  const response = await ctx.fetch(address.toString(), {\n    method: init.method ?? \"GET\",\n    signal: ctx.signal,\n    headers: {\n      Authorization: `Bearer ${target.account.fields.access_token}`,\n      ...(init.body === undefined ? {} : { \"content-type\": \"application/json\" }),\n    },\n    body: init.body === undefined ? undefined : JSON.stringify(init.body),\n  });\n  if (response.status === 204) return {};\n  return decodeJson(response, json());\n};\n\n/** Fetches a body as text, for exports and downloads. */\nexport const googleText = async (ctx: Base, target: Target, url: string): Promise<string> => {\n  const response = await ctx.fetch(url, {\n    signal: ctx.signal,\n    headers: { Authorization: `Bearer ${target.account.fields.access_token}` },\n  });\n  if (!response.ok) throw new Error(`Google answered ${response.status}: ${(await response.text()).slice(0, 500)}`);\n  return response.text();\n};\n\n/** Bounded parallelism, so a 50-thread search does not open 50 connections at once. */\nexport const mapLimit = async <T, R>(items: readonly T[], limit: number, run: (item: T) => Promise<R>): Promise<R[]> => {\n  const results: R[] = new Array(items.length);\n  let next = 0;\n  const worker = async () => {\n    while (next < items.length) {\n      const index = next++;\n      results[index] = await run(items[index]!);\n    }\n  };\n  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));\n  return results;\n};\n\nconst bytesToBinary = (bytes: Uint8Array) => {\n  let binary = \"\";\n  for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));\n  return binary;\n};\n\n/** UTF-8 text as base64url, the encoding Gmail's `raw` field takes. */\nexport const base64url = (text: string) =>\n  btoa(bytesToBinary(new TextEncoder().encode(text))).replaceAll(\"+\", \"-\").replaceAll(\"/\", \"_\").replace(/=+$/, \"\");\n\n/** Base64 for a MIME body, wrapped at 76 characters. */\nexport const base64Lines = (text: string) => btoa(bytesToBinary(new TextEncoder().encode(text))).replace(/.{76}/g, \"$&\\r\\n\");\n\n/** Decodes a Gmail body part (base64url bytes) in the part's charset. */\nexport const decodeBody = (data: string, charset = \"utf-8\") => {\n  const binary = atob(data.replaceAll(\"-\", \"+\").replaceAll(\"_\", \"/\"));\n  const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));\n  try {\n    return new TextDecoder(charset).decode(bytes);\n  } catch {\n    return new TextDecoder(\"utf-8\").decode(bytes);\n  }\n};\n\nconst entities: Record<string, string> = { amp: \"&\", lt: \"<\", gt: \">\", quot: '\"', apos: \"'\", nbsp: \" \" };\n\n/** Readable text from an HTML body: block elements become line breaks, tags and scripts go. */\nexport const htmlToText = (html: string) =>\n  html\n    .replace(/<(script|style|head)[\\s\\S]*?<\\/\\1>/gi, \"\")\n    .replace(/<br\\s*\\/?>/gi, \"\\n\")\n    .replace(/<\\/(p|div|tr|li|h[1-6]|blockquote)>/gi, \"\\n\")\n    .replace(/<li[^>]*>/gi, \"- \")\n    .replace(/<a\\s[^>]*href=\"([^\"]*)\"[^>]*>([\\s\\S]*?)<\\/a>/gi, (_, href: string, text: string) =>\n      text.trim() && !href.startsWith(\"mailto:\") && text.trim() !== href ? `${text} (${href})` : text,\n    )\n    .replace(/<[^>]+>/g, \"\")\n    .replace(/&(#x?[0-9a-f]+|\\w+);/gi, (match, code: string) => {\n      if (code.startsWith(\"#x\") || code.startsWith(\"#X\")) return String.fromCodePoint(parseInt(code.slice(2), 16));\n      if (code.startsWith(\"#\")) return String.fromCodePoint(parseInt(code.slice(1), 10));\n      return entities[code.toLowerCase()] ?? match;\n    })\n    .replace(/[ \\t]+\\n/g, \"\\n\")\n    .replace(/\\n{3,}/g, \"\\n\\n\")\n    .trim();\n\n/** A header value with non-ASCII text as an RFC 2047 encoded word. */\nexport const encodeHeader = (value: string) =>\n  /^[\\x20-\\x7e]*$/.test(value) ? value : `=?UTF-8?B?${btoa(bytesToBinary(new TextEncoder().encode(value)))}?=`;\n\n/** Milliseconds a zone is ahead of UTC at an instant. */\nconst zoneOffset = (zone: string, instant: number) => {\n  const parts = Object.fromEntries(\n    new Intl.DateTimeFormat(\"en-US\", {\n      timeZone: zone,\n      hourCycle: \"h23\",\n      year: \"numeric\",\n      month: \"2-digit\",\n      day: \"2-digit\",\n      hour: \"2-digit\",\n      minute: \"2-digit\",\n      second: \"2-digit\",\n    })\n      .formatToParts(new Date(instant))\n      .map((part) => [part.type, part.value]),\n  );\n  const asUtc = Date.UTC(+parts.year!, +parts.month! - 1, +parts.day!, +parts.hour!, +parts.minute!, +parts.second!);\n  return asUtc - Math.floor(instant / 1000) * 1000;\n};\n\n/** The instant a wall-clock time in a zone names. */\nexport const zonedTime = (zone: string, year: number, month: number, day: number, hour = 0, minute = 0) => {\n  const guess = Date.UTC(year, month - 1, day, hour, minute);\n  const first = guess - zoneOffset(zone, guess);\n  return first - (zoneOffset(zone, first) - zoneOffset(zone, guess));\n};\n\n/** The calendar date (year, month, day) of an instant in a zone. */\nexport const zonedDate = (zone: string, instant: number) => {\n  const [year, month, day] = new Intl.DateTimeFormat(\"en-CA\", { timeZone: zone, year: \"numeric\", month: \"2-digit\", day: \"2-digit\" })\n    .format(new Date(instant))\n    .split(\"-\")\n    .map(Number) as [number, number, number];\n  return { year, month, day };\n};\n\n/**\n * An instant from what a model is likely to pass: an RFC 3339 time, a date (midnight in `zone`),\n * \"now\", \"today\", \"tomorrow\" or \"yesterday\".\n */\nexport const parseTime = (value: string, zone: string, now = Date.now()) => {\n  const text = value.trim().toLowerCase();\n  if (text === \"now\") return now;\n  const shift = { yesterday: -1, today: 0, tomorrow: 1 }[text];\n  if (shift !== undefined) {\n    const today = zonedDate(zone, now);\n    return zonedTime(zone, today.year, today.month, today.day + shift);\n  }\n  const date = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(text);\n  if (date) return zonedTime(zone, +date[1]!, +date[2]!, +date[3]!);\n  const local = /^(\\d{4})-(\\d{2})-(\\d{2})t(\\d{2}):(\\d{2})(?::\\d{2}(?:\\.\\d+)?)?$/.exec(text);\n  if (local) return zonedTime(zone, +local[1]!, +local[2]!, +local[3]!, +local[4]!, +local[5]!);\n  const instant = Date.parse(value);\n  if (Number.isNaN(instant))\n    throw new Error(`Could not read the time \"${value}\". Use RFC 3339 (2026-10-14T09:00:00-07:00), a date (2026-10-14), \"now\", \"today\" or \"tomorrow\".`);\n  return instant;\n};\n\nconst TimeZone = object({ value: string() });\n\n/** The zone of the account's Calendar settings, which dates without an offset are read in. */\nexport const calendarZone = async (ctx: Base, target: Target) =>\n  ctx.cache.forAccount(target.account).get({\n    key: \"calendar-zone\",\n    schema: string(),\n    freshFor: \"1 day\",\n    load: async ({ fetch, signal }) => {\n      const response = await fetch(\"https://www.googleapis.com/calendar/v3/users/me/settings/timezone\", {\n        signal,\n        headers: { Authorization: `Bearer ${target.account.fields.access_token}` },\n      });\n      return (await decodeJson(response, TimeZone)).value;\n    },\n  });\n"}]}