diff --git a/.gitignore b/.gitignore index a14702c..825e43e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .env.test.local .env.production.local .env.local +.local/ # caches .eslintcache diff --git a/README.md b/README.md index e982db5..b5cdfd2 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -Corbits Tools for Google +# @corbits/google-tools + +Google API clients and Interchange tools, starting with Gmail. Authentication +is supplied through the Interchange `gmail-api` mediated credential; the +package never accepts or returns raw tokens. + +## Install + +```bash +bun add github:corbitsdev/google-tools +``` + +## Working on it + +```bash +bun install +bun run typecheck +bun test +bun run build +``` + +The tests inject `fetch` implementations and do not call Gmail. + +## Live Gmail checks + +Use a dedicated Gmail fixture account and a Desktop OAuth client granted only +`gmail.modify`. The read smoke requires a query that matches exactly one +one-message thread: + +```bash +GMAIL_LIVE_TEST=1 \ +GMAIL_LIVE_CLIENT_ID='...' \ +GMAIL_LIVE_CLIENT_SECRET='...' \ +GMAIL_LIVE_FIXTURE_QUERY='subject:(interchange-gmail-e2e-fixture)' \ +bun run test:live +``` + +Add `GMAIL_LIVE_MUTATION_TEST=1` to also test draft creation and label changes. +The mutation suite deletes its draft and restores the fixture labels. It never +sends email. + +The first run opens Google OAuth and stores the refresh token in the ignored +`.local/gmail-live-token.json` file. Set `GMAIL_LIVE_TOKEN_FILE` when using a +different token file. Credentials and message contents are not logged. + +## License + +LGPL-2.1-only. See [LICENSE](./LICENSE). diff --git a/bun.lock b/bun.lock index bfcc771..9025fb3 100644 --- a/bun.lock +++ b/bun.lock @@ -4,23 +4,67 @@ "workspaces": { "": { "name": "@corbits/google-tools", + "dependencies": { + "arktype": "^2.2.3", + }, "devDependencies": { - "@types/bun": "latest", + "@intx/agent": "^0.3.0", + "@intx/types": "^0.3.0", + "@types/bun": "1.3.14", + "@types/node": "22.10.5", + "typescript": "5.9.3", }, "peerDependencies": { - "typescript": "^5", + "@intx/agent": "*", + "@intx/types": "*", }, + "optionalPeers": [ + "@intx/agent", + "@intx/types", + ], }, }, "packages": { - "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@ark/schema": ["@ark/schema@0.56.2", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg=="], + + "@ark/util": ["@ark/util@0.56.2", "", {}, "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ=="], + + "@intx/agent": ["@intx/agent@0.3.0", "", { "dependencies": { "@intx/inference": "0.3.0", "@intx/log": "0.3.0", "@intx/mime": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-a7cgmH8FSsGQ7BpLKQhEwZYRfofWh3lWEGgKFsk53zdgjndtDfqAf2I64QMPAqC2Y2Npag/z1NA5lEsIqAGvPg=="], + + "@intx/crypto": ["@intx/crypto@0.3.0", "", { "dependencies": { "@intx/types": "0.3.0" } }, "sha512-NsRzvkFGb0Pcsm9uLFFSXNl6Cbu+ii6VkYAw81qs9IbZzbAFfsZSUVQaX6EZuPNyJ8KK4KmBtqmioq1mhXsJww=="], + + "@intx/inference": ["@intx/inference@0.3.0", "", { "dependencies": { "@intx/log": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-2DxqWRp5cSziXgDXPomSy0HTLVesBCtZErsKXYFnliTwYM6U+GRKk5rzwWMMgMWMFbtUaiogPExcNQYBYmfjQA=="], + + "@intx/log": ["@intx/log@0.3.0", "", { "dependencies": { "@logtape/hono": "^2.0.2", "@logtape/logtape": "^2.0.2" }, "peerDependencies": { "hono": "^4.0.0" }, "optionalPeers": ["hono"] }, "sha512-iooPSZjiEUO1A91X5STRhOAk+2RprliB4nbriV/dOOz86kuxZ60y5T8ISQ5Jaj1US870gntN6QwnefY38aWi2Q=="], + + "@intx/mime": ["@intx/mime@0.3.0", "", { "dependencies": { "@intx/crypto": "0.3.0", "@intx/types": "0.3.0", "arktype": "^2.1.29" } }, "sha512-jpKZZpfWRQJ6fI8BEpHnr83N+1ZHrnOMY978B/6bgcjFn3qtm1f3KiR0yaQ8eF3r1VF327r3H2qiCCx0OFN9Ow=="], + + "@intx/types": ["@intx/types@0.3.0", "", { "dependencies": { "arktype": "^2.1.29", "semver": "^7.7.2" } }, "sha512-PJ+v3IhtfZ4J7ZqJ8E39TteRMnC6Y503Q0JeCJdskkK+jhcjx01z7TJSozucagauAlpyh8Hn/9CyWFl53Ji0DQ=="], + + "@logtape/hono": ["@logtape/hono@2.3.2", "", { "peerDependencies": { "@logtape/logtape": "^2.3.2", "hono": "^4.0.0" } }, "sha512-fwjfktpQSucHQCO6OYCHUrcgwHPhU41VKTJt2gtqSoW3MjYMUtdZJTGJx2JTAj+8zyQ1cXqugkEKVG07wwSU4g=="], - "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@logtape/logtape": ["@logtape/logtape@2.3.2", "", {}, "sha512-SfxHfSdDlTp6tLpYP5uezNitSCTQUB3FZ1MXDbgDBN9t9kvF+XL0rOGTIa/pJcDMy1o9FELaZ25E/Ypmsf5fNQ=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], + + "arkregex": ["arkregex@0.0.8", "", { "dependencies": { "@ark/util": "0.56.2" } }, "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ=="], + + "arktype": ["arktype@2.2.3", "", { "dependencies": { "@ark/schema": "0.56.2", "@ark/util": "0.56.2", "arkregex": "0.0.8" } }, "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + + "bun-types/@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + + "bun-types/@types/node/undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], } } diff --git a/index.ts b/index.ts deleted file mode 100644 index 2a5e4b8..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); diff --git a/package.json b/package.json index 1b47b20..65ee917 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,14 @@ "version": "0.1.0", "type": "module", "license": "LGPL-2.1-only", - "description": "Corbits Tools for Google", + "description": "Corbits Google tools for Interchange, starting with Gmail.", + "keywords": [ + "corbits", + "google", + "gmail", + "tools", + "interchange" + ], "author": "Corbits", "homepage": "https://github.com/corbitsdev/google-tools#readme", "repository": { @@ -13,11 +20,66 @@ "bugs": { "url": "https://github.com/corbitsdev/google-tools/issues" }, - "module": "index.ts", - "devDependencies": { - "@types/bun": "latest" + "engines": { + "node": ">=22" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./sidecar-bundle": { + "bun": "./src/sidecar-bundle.ts", + "types": "./dist/sidecar-bundle.d.ts", + "default": "./dist/sidecar-bundle.js" + } + }, + "interchange": { + "tools": "./dist/sidecar-bundle.js", + "credentials": [ + { + "handle": "gmail-api", + "scopes": [ + "https://www.googleapis.com/auth/gmail.modify" + ] + } + ] + }, + "files": [ + "dist", + "src", + "!src/**/*.test.ts", + "LICENSE", + "README.md" + ], + "sideEffects": false, + "scripts": { + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", + "typecheck:live": "tsc -p tsconfig.live.json --noEmit", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "prepack": "bun run build", + "test": "bun test src tests/live/credential.test.ts tests/live/fixtures.test.ts tests/live/oauth.test.ts", + "test:live": "bun test tests/live" }, "peerDependencies": { - "typescript": "^5" + "@intx/agent": "*", + "@intx/types": "*" + }, + "peerDependenciesMeta": { + "@intx/agent": { "optional": true }, + "@intx/types": { "optional": true } + }, + "devDependencies": { + "@intx/agent": "^0.3.0", + "@intx/types": "^0.3.0", + "@types/bun": "1.3.14", + "@types/node": "22.10.5", + "typescript": "5.9.3" + }, + "dependencies": { + "arktype": "^2.2.3" } } diff --git a/src/client/client.test.ts b/src/client/client.test.ts new file mode 100644 index 0000000..876796f --- /dev/null +++ b/src/client/client.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; + +import { + createGmailClient, + GmailApiError, + GMAIL_API_BASE_URL, + type GmailFetch, +} from "./client.js"; + +describe("createGmailClient", () => { + test("lists threads through the Gmail API without adding authorization", async () => { + const fetchImpl: GmailFetch = async (input, init) => { + const url = new URL(String(input)); + expect(url.origin + url.pathname).toBe(`${GMAIL_API_BASE_URL}/threads`); + expect(url.searchParams.get("q")).toBe("from:ada@example.com"); + expect(url.searchParams.get("maxResults")).toBe("10"); + expect(url.searchParams.get("pageToken")).toBe("next-1"); + expect(url.searchParams.get("includeSpamTrash")).toBe("true"); + expect(new Headers(init?.headers).get("Authorization")).toBeNull(); + expect(new Headers(init?.headers).get("User-Agent")).toBeNull(); + return new Response( + JSON.stringify({ + threads: [{ id: "thread-1", futureThreadField: true }], + futureEnvelopeField: { accepted: true }, + }), + { status: 200 }, + ); + }; + + const client = createGmailClient({ fetchImpl }); + await expect( + client.listThreads({ + query: "from:ada@example.com", + pageSize: 10, + pageToken: "next-1", + includeTrash: true, + }), + ).resolves.toMatchObject({ threads: [{ id: "thread-1" }] }); + }); + + test("encodes message IDs and selects the requested format", async () => { + const fetchImpl: GmailFetch = async (input) => { + const url = new URL(String(input)); + expect(url.origin + url.pathname).toBe( + `${GMAIL_API_BASE_URL}/messages/message%2F1`, + ); + expect(url.searchParams.get("format")).toBe("metadata"); + return new Response(JSON.stringify({ id: "message/1" }), { status: 200 }); + }; + + const client = createGmailClient({ fetchImpl }); + await expect( + client.getMessage("message/1", { format: "metadata", metadataHeaders: [] }), + ).resolves.toEqual({ id: "message/1" }); + }); + + test("rejects malformed successful Gmail responses", async () => { + const fetchImpl: GmailFetch = async () => + new Response(JSON.stringify({ messages: [{ threadId: "thread-1" }] }), { + status: 200, + }); + const client = createGmailClient({ fetchImpl }); + + await expect( + client.listMessages({ query: "in:inbox", pageSize: 10 }), + ).rejects.toThrow("Gmail API response did not match the expected list-messages shape"); + }); + + test("rejects malformed Gmail headers", async () => { + const fetchImpl: GmailFetch = async () => + new Response( + JSON.stringify({ + id: "message-1", + payload: { headers: [{ name: "Subject", value: 42 }] }, + }), + { status: 200 }, + ); + const client = createGmailClient({ fetchImpl }); + + await expect( + client.getMessage("message-1", { format: "full" }), + ).rejects.toThrow("Gmail API response did not match the expected get-message shape"); + }); + + test("rejects an empty successful response body", async () => { + const fetchImpl: GmailFetch = async () => new Response("", { status: 200 }); + const client = createGmailClient({ fetchImpl }); + + await expect(client.listLabels()).rejects.toThrow("response body was empty"); + }); + + test("maps non-success responses to GmailApiError", async () => { + const fetchImpl: GmailFetch = async () => + new Response('{"error":{"message":"forbidden"}}', { + status: 403, + statusText: "Forbidden", + }); + const client = createGmailClient({ fetchImpl }); + + try { + await client.listLabels(); + throw new Error("Expected listLabels to throw"); + } catch (error) { + expect(error).toBeInstanceOf(GmailApiError); + if (!(error instanceof GmailApiError)) throw error; + expect(error.status).toBe(403); + expect(error.body).toContain("forbidden"); + } + }); + + test("preserves the complete Gmail error response body", async () => { + const body = "x".repeat(2_001); + const fetchImpl: GmailFetch = async () => + new Response(body, { status: 400, statusText: "Bad Request" }); + const client = createGmailClient({ fetchImpl }); + + try { + await client.listLabels(); + throw new Error("Expected listLabels to throw"); + } catch (error) { + if (!(error instanceof GmailApiError)) throw error; + expect(error.body).toBe(body); + } + }); +}); diff --git a/src/client/client.ts b/src/client/client.ts new file mode 100644 index 0000000..66268fa --- /dev/null +++ b/src/client/client.ts @@ -0,0 +1,364 @@ +import { GmailApiError } from "./errors.js"; +import { + GmailDraftSchema, + GmailListDraftsResponseSchema, + GmailListLabelsResponseSchema, + GmailListMessagesResponseSchema, + GmailListThreadsResponseSchema, + GmailMessageSchema, + GmailThreadSchema, + parseGmailResponse, + type GmailDraft, + type GmailHeader, + type GmailListDraftsResponse, + type GmailListLabelsResponse, + type GmailListMessagesResponse, + type GmailListThreadsResponse, + type GmailMessage, + type GmailMessagePart, + type GmailResponseSchema, + type GmailThread, +} from "./schemas.js"; + +export { GmailApiError } from "./errors.js"; +export type { + GmailDraft, + GmailHeader, + GmailListDraftsResponse, + GmailListLabelsResponse, + GmailListMessagesResponse, + GmailListThreadsResponse, + GmailMessage, + GmailMessagePart, + GmailThread, +} from "./schemas.js"; + +export const GMAIL_API_BASE_URL = + "https://gmail.googleapis.com/gmail/v1/users/me"; + +export type GmailQueryValue = + | string + | number + | boolean + | readonly string[] + | undefined + | null; + +export type GmailRequest = { + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + path: string; + query?: Record; + body?: unknown; + signal?: AbortSignal; +}; + +export type GmailFormat = "full" | "metadata" | "minimal"; + +export type GmailModifyLabels = { + addLabelIds?: readonly string[]; + removeLabelIds?: readonly string[]; +}; + +export type GmailFetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export type GmailClient = { + listThreads(options: { + query?: string; + pageSize: number; + pageToken?: string; + includeTrash: boolean; + signal?: AbortSignal; + }): Promise; + getThread( + id: string, + options: { + format: GmailFormat; + metadataHeaders?: readonly string[]; + signal?: AbortSignal; + }, + ): Promise; + getMessage( + id: string, + options: { + format: GmailFormat; + metadataHeaders?: readonly string[]; + signal?: AbortSignal; + }, + ): Promise; + listDrafts(options: { + pageSize: number; + pageToken?: string; + signal?: AbortSignal; + }): Promise; + listMessages(options: { + query: string; + pageSize: number; + pageToken?: string; + signal?: AbortSignal; + }): Promise; + getDraft( + id: string, + options: { + format: GmailFormat; + metadataHeaders?: readonly string[]; + signal?: AbortSignal; + }, + ): Promise; + createDraft(options: { + raw: string; + threadId?: string; + signal?: AbortSignal; + }): Promise; + modifyMessage( + id: string, + labels: GmailModifyLabels, + signal?: AbortSignal, + ): Promise; + modifyThread( + id: string, + labels: GmailModifyLabels, + signal?: AbortSignal, + ): Promise; + listLabels(signal?: AbortSignal): Promise; +}; + +export type CreateGmailClientOptions = { fetchImpl: GmailFetch }; + +const REQUEST_TIMEOUT_MS = 30_000; + +function joinUrl(path: string): string { + if (/^https?:\/\//i.test(path)) { + throw new Error("Gmail API request path must be relative"); + } + return `${GMAIL_API_BASE_URL}${path.startsWith("/") ? path : `/${path}`}`; +} + +function applyQuery( + url: URL, + query: Record | undefined, +): void { + if (query === undefined) return; + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) continue; + if (Array.isArray(value)) { + for (const item of value) url.searchParams.append(key, item); + continue; + } + url.searchParams.set(key, String(value)); + } +} + +function requestSignal(signal: AbortSignal | undefined): AbortSignal { + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + return signal === undefined ? timeout : AbortSignal.any([signal, timeout]); +} + +function responseBodyError( + status: number, + message: string, + cause: unknown, +): Error { + return new Error( + `Gmail API request failed: ${message} (status ${String(status)})`, + { cause }, + ); +} + +export function createGmailClient({ fetchImpl }: CreateGmailClientOptions): GmailClient { + if (typeof fetchImpl !== "function") { + throw new Error("createGmailClient: provide fetchImpl"); + } + + async function request( + requestOptions: GmailRequest, + responseSchema: GmailResponseSchema, + operation: string, + ): Promise { + const url = new URL(joinUrl(requestOptions.path)); + applyQuery(url, requestOptions.query); + + const headers: Record = { + Accept: "application/json", + }; + let body: string | undefined; + if (requestOptions.body !== undefined) { + body = JSON.stringify(requestOptions.body); + headers["Content-Type"] = "application/json"; + } + + let response: Response; + try { + response = await fetchImpl(url.toString(), { + method: requestOptions.method, + headers, + body, + signal: requestSignal(requestOptions.signal), + }); + } catch (cause) { + throw new Error( + `Gmail API request failed: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + } + + let raw: string; + try { + raw = await response.text(); + } catch (cause) { + throw responseBodyError(response.status, "could not read response body", cause); + } + + if (!response.ok) { + throw new GmailApiError(response.status, response.statusText, raw); + } + + if (raw.length === 0) { + throw responseBodyError(response.status, "response body was empty", undefined); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + throw responseBodyError(response.status, "invalid JSON body", cause); + } + return parseGmailResponse(responseSchema, parsed, operation); + } + + return { + listThreads: (options) => + request( + { + method: "GET", + path: "/threads", + query: { + q: options.query, + maxResults: options.pageSize, + pageToken: options.pageToken, + includeSpamTrash: options.includeTrash, + }, + signal: options.signal, + }, + GmailListThreadsResponseSchema, + "list-threads", + ), + getThread: (id, options) => + request( + { + method: "GET", + path: `/threads/${encodeURIComponent(id)}`, + query: { + format: options.format, + metadataHeaders: options.metadataHeaders, + }, + signal: options.signal, + }, + GmailThreadSchema, + "get-thread", + ), + getMessage: (id, options) => + request( + { + method: "GET", + path: `/messages/${encodeURIComponent(id)}`, + query: { + format: options.format, + metadataHeaders: options.metadataHeaders, + }, + signal: options.signal, + }, + GmailMessageSchema, + "get-message", + ), + listDrafts: (options) => + request( + { + method: "GET", + path: "/drafts", + query: { + maxResults: options.pageSize, + pageToken: options.pageToken, + }, + signal: options.signal, + }, + GmailListDraftsResponseSchema, + "list-drafts", + ), + listMessages: (options) => + request( + { + method: "GET", + path: "/messages", + query: { + q: options.query, + maxResults: options.pageSize, + pageToken: options.pageToken, + }, + signal: options.signal, + }, + GmailListMessagesResponseSchema, + "list-messages", + ), + getDraft: (id, options) => + request( + { + method: "GET", + path: `/drafts/${encodeURIComponent(id)}`, + query: { + format: options.format, + metadataHeaders: options.metadataHeaders, + }, + signal: options.signal, + }, + GmailDraftSchema, + "get-draft", + ), + createDraft: (options) => + request( + { + method: "POST", + path: "/drafts", + body: { + message: { + raw: options.raw, + ...(options.threadId === undefined ? {} : { threadId: options.threadId }), + }, + }, + signal: options.signal, + }, + GmailDraftSchema, + "create-draft", + ), + modifyMessage: (id, labels, signal) => + request( + { + method: "POST", + path: `/messages/${encodeURIComponent(id)}/modify`, + body: labels, + signal, + }, + GmailMessageSchema, + "modify-message", + ), + modifyThread: (id, labels, signal) => + request( + { + method: "POST", + path: `/threads/${encodeURIComponent(id)}/modify`, + body: labels, + signal, + }, + GmailThreadSchema, + "modify-thread", + ), + listLabels: (signal) => + request( + { method: "GET", path: "/labels", signal }, + GmailListLabelsResponseSchema, + "list-labels", + ), + }; +} diff --git a/src/client/errors.ts b/src/client/errors.ts new file mode 100644 index 0000000..6279e54 --- /dev/null +++ b/src/client/errors.ts @@ -0,0 +1,13 @@ +export class GmailApiError extends Error { + readonly status: number; + readonly statusText: string; + readonly body: string; + + constructor(status: number, statusText: string, body: string) { + super(`Gmail API request failed (${String(status)} ${statusText})`); + this.name = "GmailApiError"; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..9066457 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,2 @@ +export * from "./client.js"; +export * from "./errors.js"; diff --git a/src/client/schemas.ts b/src/client/schemas.ts new file mode 100644 index 0000000..78f87c5 --- /dev/null +++ b/src/client/schemas.ts @@ -0,0 +1,107 @@ +import { ArkErrors, scope } from "arktype"; + +const GmailSchemas = scope({ + GmailHeader: { name: "string", value: "string" }, + GmailMessageBody: { + "data?": "string", + "size?": "number", + "attachmentId?": "string", + }, + GmailMessagePart: { + "mimeType?": "string", + "filename?": "string", + "headers?": "GmailHeader[]", + "body?": "GmailMessageBody", + "parts?": "GmailMessagePart[]", + }, + GmailMessage: { + id: "string", + "threadId?": "string", + "labelIds?": "string[]", + "snippet?": "string", + "historyId?": "string", + "internalDate?": "string", + "sizeEstimate?": "number", + "raw?": "string", + "payload?": "GmailMessagePart", + }, + GmailThread: { + id: "string", + "historyId?": "string", + "messages?": "GmailMessage[]", + }, + GmailThreadReference: { + id: "string", + "snippet?": "string", + "historyId?": "string", + }, + GmailMessageReference: { id: "string", "threadId?": "string" }, + GmailDraft: { id: "string", "message?": "GmailMessage" }, + GmailLabel: { + id: "string", + name: "string", + "type?": "string", + "labelListVisibility?": "string", + "messageListVisibility?": "string", + }, + GmailListThreadsResponse: { + "threads?": "GmailThreadReference[]", + "nextPageToken?": "string", + "resultSizeEstimate?": "number", + }, + GmailListMessagesResponse: { + "messages?": "GmailMessageReference[]", + "nextPageToken?": "string", + "resultSizeEstimate?": "number", + }, + GmailListDraftsResponse: { + "drafts?": "GmailDraft[]", + "nextPageToken?": "string", + "resultSizeEstimate?": "number", + }, + GmailListLabelsResponse: { "labels?": "GmailLabel[]" }, +}).export(); + +export const GmailHeaderSchema = GmailSchemas.GmailHeader; +export type GmailHeader = typeof GmailHeaderSchema.infer; + +export const GmailMessagePartSchema = GmailSchemas.GmailMessagePart; +export type GmailMessagePart = typeof GmailMessagePartSchema.infer; + +export const GmailMessageSchema = GmailSchemas.GmailMessage; +export type GmailMessage = typeof GmailMessageSchema.infer; + +export const GmailThreadSchema = GmailSchemas.GmailThread; +export type GmailThread = typeof GmailThreadSchema.infer; + +export const GmailDraftSchema = GmailSchemas.GmailDraft; +export type GmailDraft = typeof GmailDraftSchema.infer; + +export const GmailListThreadsResponseSchema = GmailSchemas.GmailListThreadsResponse; +export type GmailListThreadsResponse = typeof GmailListThreadsResponseSchema.infer; + +export const GmailListMessagesResponseSchema = GmailSchemas.GmailListMessagesResponse; +export type GmailListMessagesResponse = typeof GmailListMessagesResponseSchema.infer; + +export const GmailListDraftsResponseSchema = GmailSchemas.GmailListDraftsResponse; +export type GmailListDraftsResponse = typeof GmailListDraftsResponseSchema.infer; + +export const GmailListLabelsResponseSchema = GmailSchemas.GmailListLabelsResponse; +export type GmailListLabelsResponse = typeof GmailListLabelsResponseSchema.infer; + +export type GmailResponseSchema = (response: unknown) => T | ArkErrors; + +export function parseGmailResponse( + schema: GmailResponseSchema, + response: unknown, + operation: string, +): T { + const result = schema(response); + if (result instanceof ArkErrors) { + throw new Error( + `Gmail API response did not match the expected ${operation} shape`, + { cause: result }, + ); + } + return result; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..318f449 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +export * from "./client/index.js"; +export * from "./tools/index.js"; diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts new file mode 100644 index 0000000..773e65d --- /dev/null +++ b/src/sidecar-bundle.ts @@ -0,0 +1,29 @@ +import { defineTool, type BaseEnv } from "@intx/agent"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { createGmailTools } from "./tools/create-tools.js"; +import { TOOL_DEFINITIONS } from "./tools/definitions.js"; + +const MUTATING_TOOL_NAMES = new Set([ + "gmail_create_draft", + "gmail_label_message", + "gmail_unlabel_message", + "gmail_label_thread", + "gmail_unlabel_thread", +]); + +export interface GmailToolEnv extends BaseEnv { + capabilities: RuntimeCapabilities; +} + +export const gmail = defineTool({ + id: "@corbits/google-tools/sidecar-bundle", + requires: ["capabilities"], + definitions: TOOL_DEFINITIONS.map((definition) => ({ + name: definition.name, + ...(MUTATING_TOOL_NAMES.has(definition.name) + ? { approval: "ask" as const } + : {}), + })), + factory: (env) => createGmailTools({ capabilities: env.capabilities }), +}); diff --git a/src/tools/create-tools.test.ts b/src/tools/create-tools.test.ts new file mode 100644 index 0000000..9ef55b4 --- /dev/null +++ b/src/tools/create-tools.test.ts @@ -0,0 +1,553 @@ +import { describe, expect, test } from "bun:test"; +import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import type { GmailFetch } from "../client/index.js"; +import { GMAIL_CREDENTIAL_HANDLE, TOOL_DEFINITIONS } from "./definitions.js"; +import { createGmailTools } from "./create-tools.js"; +import { createRawDraft } from "./drafts.js"; +import { gmail } from "../sidecar-bundle.js"; + +function testCapabilities(fetchImpl: GmailFetch, handle = GMAIL_CREDENTIAL_HANDLE) { + return createRuntimeCapabilities({ + credentials: { + async resolve(requested) { + if (requested !== handle) { + throw new Error(`unbound credential handle: ${requested}`); + } + return { kind: "http", fetch: fetchImpl, dispose() {} }; + }, + }, + }); +} + +function createFetchImpl(handler: GmailFetch): GmailFetch { + return handler; +} + +function encodeBase64Url(value: string): string { + return Buffer.from(value).toString("base64url"); +} + +function jsonRequestBody(init: RequestInit | undefined): Record { + if (typeof init?.body !== "string") throw new Error("expected JSON string body"); + const parsed: unknown = JSON.parse(init.body); + if (!isRecord(parsed)) { + throw new Error("expected JSON object body"); + } + return parsed; +} + +function draftRawFromRequest(init: RequestInit | undefined): string { + const body = jsonRequestBody(init); + const message = body.message; + if (!isRecord(message)) { + throw new Error("expected draft message object"); + } + if (typeof message.raw !== "string") throw new Error("expected draft raw message"); + return message.raw; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +describe("Gmail tool definitions", () => { + test("declares the complete Google-compatible Gmail surface", () => { + expect(TOOL_DEFINITIONS.map((definition) => definition.name).sort()).toEqual([ + "gmail_create_draft", + "gmail_get_message", + "gmail_get_thread", + "gmail_label_message", + "gmail_label_thread", + "gmail_list_drafts", + "gmail_list_labels", + "gmail_search_threads", + "gmail_unlabel_message", + "gmail_unlabel_thread", + ]); + }); + + test("publishes strict generated JSON Schemas with Google defaults", () => { + for (const definition of TOOL_DEFINITIONS) { + expect(definition.inputSchema).toMatchObject({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + additionalProperties: false, + }); + } + const searchThreads = TOOL_DEFINITIONS.find( + (definition) => definition.name === "gmail_search_threads", + ); + expect(searchThreads?.inputSchema).toMatchObject({ + properties: { + pageSize: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + includeTrash: { type: "boolean", default: false }, + view: { + default: "THREAD_VIEW_MINIMAL", + anyOf: [ + { const: "THREAD_VIEW_METADATA_ONLY" }, + { const: "THREAD_VIEW_MINIMAL" }, + ], + }, + }, + }); + }); + + test("requires approval for every mailbox mutation", () => { + const approvals = new Map( + gmail.definitions.map((definition) => [definition.name, definition.approval]), + ); + for (const name of [ + "gmail_create_draft", + "gmail_label_message", + "gmail_unlabel_message", + "gmail_label_thread", + "gmail_unlabel_thread", + ]) { + expect(approvals.get(name)).toBe("ask"); + } + }); +}); + +describe("createGmailTools", () => { + test("searches through the mediated credential with Google-compatible defaults", async () => { + const fetchImpl = createFetchImpl(async (input, init) => { + const url = new URL(String(input)); + expect(url.origin).toBe("https://gmail.googleapis.com"); + expect(new Headers(init?.headers).get("Authorization")).toBeNull(); + if (url.pathname === "/gmail/v1/users/me/threads") { + expect(url.searchParams.get("q")).toBe("subject:fixture"); + expect(url.searchParams.get("maxResults")).toBe("20"); + expect(url.searchParams.get("includeSpamTrash")).toBe("false"); + return new Response( + JSON.stringify({ + threads: [{ id: "thread-1" }], + nextPageToken: "next-2", + resultSizeEstimate: 101, + }), + ); + } + + expect(url.pathname).toBe("/gmail/v1/users/me/threads/thread-1"); + expect(url.searchParams.get("format")).toBe("metadata"); + expect(url.searchParams.getAll("metadataHeaders")).toEqual([ + "Subject", + "From", + "To", + "Cc", + "Date", + ]); + return new Response( + JSON.stringify({ + id: "thread-1", + messages: [ + { + id: "message-1", + threadId: "thread-1", + labelIds: ["INBOX"], + snippet: "fixture message", + payload: { + headers: [ + { name: "Subject", value: "Fixture" }, + { name: "From", value: "ada@example.com" }, + { name: "To", value: "team@example.com" }, + { name: "Date", value: "Tue, 19 Aug 2026 12:00:00 +0000" }, + ], + }, + }, + ], + }), + ); + }); + + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const result = await tools.run( + { + id: "call-1", + name: "gmail_search_threads", + arguments: { query: "subject:fixture" }, + }, + new AbortController().signal, + ); + + expect(result).toEqual({ + callId: "call-1", + content: { + data: { + threads: [ + { + id: "thread-1", + messages: [ + { + id: "message-1", + threadId: "thread-1", + labelIds: ["INBOX"], + date: "2026-08-19", + snippet: "fixture message", + subject: "Fixture", + sender: "ada@example.com", + toRecipients: ["team@example.com"], + }, + ], + }, + ], + nextPageToken: "next-2", + resultCountEstimate: "101", + }, + }, + }); + await tools.dispose(); + }); + + test("returns complete decoded text, HTML, and attachment metadata", async () => { + const plaintextBody = "a".repeat(70_000); + const fetchImpl = createFetchImpl(async (input) => { + expect(new URL(String(input)).pathname).toBe( + "/gmail/v1/users/me/messages/message-1", + ); + return new Response( + JSON.stringify({ + id: "message-1", + threadId: "thread-1", + labelIds: ["INBOX"], + payload: { + headers: [ + { name: "Subject", value: "Fixture" }, + { name: "From", value: "ada@example.com" }, + { name: "To", value: "grace@example.com, linus@example.com" }, + { name: "Date", value: "Tue, 19 Aug 2026 12:00:00 +0000" }, + ], + parts: [ + { mimeType: "text/plain", body: { data: encodeBase64Url(plaintextBody) } }, + { mimeType: "text/html", body: { data: encodeBase64Url("

Hello

") } }, + { + mimeType: "application/pdf", + filename: "report.pdf", + body: { attachmentId: "attachment-1", data: "not-returned" }, + }, + { + mimeType: "text/plain", + filename: "payroll.txt", + body: { + attachmentId: "attachment-2", + data: encodeBase64Url("confidential attachment"), + }, + }, + ], + }, + }), + ); + }); + + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const result = await tools.run( + { + id: "call-2", + name: "gmail_get_message", + arguments: { messageId: "message-1" }, + }, + new AbortController().signal, + ); + + expect(result.content).toEqual({ + data: { + id: "message-1", + threadId: "thread-1", + labelIds: ["INBOX"], + date: "2026-08-19", + subject: "Fixture", + sender: "ada@example.com", + toRecipients: ["grace@example.com", "linus@example.com"], + plaintextBody, + htmlBody: "

Hello

", + attachmentIds: ["attachment-1", "attachment-2"], + attachments: [ + { + id: "attachment-1", + mimeType: "application/pdf", + filename: "report.pdf", + }, + { + id: "attachment-2", + mimeType: "text/plain", + filename: "payroll.txt", + }, + ], + }, + }); + await tools.dispose(); + }); + + test("preserves commas inside quoted recipient display names", async () => { + const fetchImpl = createFetchImpl(async () => + new Response( + JSON.stringify({ + id: "message-1", + payload: { + headers: [ + { name: "To", value: '"Doe, John" , bob@example.com' }, + ], + }, + }), + ), + ); + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + + const result = await tools.run( + { + id: "call-recipient-parser", + name: "gmail_get_message", + arguments: { messageId: "message-1", messageFormat: "MINIMAL" }, + }, + new AbortController().signal, + ); + + expect(result.content).toMatchObject({ + data: { + toRecipients: ['"Doe, John" ', "bob@example.com"], + }, + }); + await tools.dispose(); + }); + + test("omits message bodies for metadata-only requests", async () => { + const fetchImpl = createFetchImpl(async (input) => { + const url = new URL(String(input)); + expect(url.searchParams.get("format")).toBe("metadata"); + expect(url.searchParams.getAll("metadataHeaders")).toEqual([]); + return new Response( + JSON.stringify({ + id: "message-1", + threadId: "thread-1", + labelIds: ["INBOX"], + internalDate: "1787140800000", + sizeEstimate: 42, + payload: { + headers: [{ name: "Subject", value: "Not exposed" }], + body: { data: encodeBase64Url("Not exposed") }, + }, + }), + ); + }); + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + + const result = await tools.run( + { + id: "call-3", + name: "gmail_get_message", + arguments: { messageId: "message-1", messageFormat: "METADATA_ONLY" }, + }, + new AbortController().signal, + ); + + expect(result.content).toEqual({ + data: { + id: "message-1", + threadId: "thread-1", + labelIds: ["INBOX"], + sizeEstimate: 42, + date: "2026-08-19", + }, + }); + await tools.dispose(); + }); + + test("fails before network access for invalid arguments", async () => { + const fetchImpl = createFetchImpl(async () => { + throw new Error("network should not run"); + }); + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + + const result = await tools.run( + { + id: "call-4", + name: "gmail_search_threads", + arguments: { pageSize: 51 }, + }, + new AbortController().signal, + ); + + expect(result.isError).toBe(true); + expect(result.content).toEqual({ + error: + "invalid tool input: pageSize must be Maximum number of threads to return; defaults to 20. (was 51)", + }); + await tools.dispose(); + }); + + test("fails closed when gmail-api is not bound", async () => { + const tools = createGmailTools({ + capabilities: testCapabilities( + createFetchImpl(async () => new Response("{}")), + "other-handle", + ), + }); + const result = await tools.run( + { id: "call-5", name: "gmail_list_labels", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBe(true); + if ( + typeof result.content !== "object" || + result.content === null || + !("error" in result.content) + ) { + throw new Error("expected a tool error"); + } + expect(String(result.content.error)).toContain(GMAIL_CREDENTIAL_HANDLE); + }); + + test("creates a plain-text draft and returns its normalized Gmail draft", async () => { + const fetchImpl = createFetchImpl(async (input, init) => { + const url = new URL(String(input)); + expect(url.pathname).toBe("/gmail/v1/users/me/drafts"); + expect(init?.method).toBe("POST"); + expect(Buffer.from(draftRawFromRequest(init), "base64url").toString()).toContain( + "To: ada@example.com\r\nSubject: Status\r\nMIME-Version: 1.0", + ); + return new Response(JSON.stringify({ id: "draft-1" })); + }); + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + + const result = await tools.run( + { + id: "call-draft", + name: "gmail_create_draft", + arguments: { to: ["ada@example.com"], subject: "Status", body: "The body" }, + }, + new AbortController().signal, + ); + + expect(result.content).toEqual({ + data: { + id: "draft-1", + }, + }); + await tools.dispose(); + }); + + test("keeps display-name recipients that Gmail accepts in raw RFC 5322 drafts", () => { + const raw = createRawDraft({ + to: ["Ada Lovelace "], + body: "Status", + }); + + expect(Buffer.from(raw, "base64url").toString()).toContain( + "To: Ada Lovelace ", + ); + }); + + test("rejects injected reply reference headers", () => { + expect(() => + createRawDraft( + { body: "Reply" }, + { + messageId: "", + references: "\r\nBcc: attacker@example.com", + }, + ), + ).toThrow('argument "references" cannot contain a newline'); + }); + + test("appends the parent message to reply references", () => { + const raw = createRawDraft( + { body: "Reply" }, + { + messageId: "", + references: "", + }, + ); + expect(Buffer.from(raw, "base64url").toString()).toContain( + "References: ", + ); + }); + + test("lists drafts, including Gmail-query-filtered drafts", async () => { + const fetchImpl = createFetchImpl(async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/gmail/v1/users/me/drafts") { + if (url.searchParams.get("maxResults") === "500") { + return new Response( + JSON.stringify({ drafts: [{ id: "draft-1", message: { id: "message-1" } }] }), + ); + } + expect(url.searchParams.get("maxResults")).toBe("20"); + return new Response(JSON.stringify({ drafts: [{ id: "draft-1" }] })); + } + if (url.pathname === "/gmail/v1/users/me/messages") { + expect(url.searchParams.get("q")).toBe("in:drafts subject:status"); + return new Response(JSON.stringify({ messages: [{ id: "message-1" }] })); + } + return new Response( + JSON.stringify({ + id: "draft-1", + message: { id: "message-1", payload: { headers: [] } }, + }), + ); + }); + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + + const result = await tools.run( + { id: "call-list-drafts", name: "gmail_list_drafts", arguments: {} }, + new AbortController().signal, + ); + expect(result.content).toEqual({ data: { drafts: [{ id: "draft-1" }] } }); + + const queryResult = await tools.run( + { + id: "call-list-drafts-query", + name: "gmail_list_drafts", + arguments: { query: "subject:status" }, + }, + new AbortController().signal, + ); + expect(queryResult.content).toEqual({ data: { drafts: [{ id: "draft-1" }] } }); + await tools.dispose(); + }); + + test("uses the original sender when a reply draft omits recipients", () => { + const raw = createRawDraft( + { body: "Reply" }, + { messageId: "", subject: "Original", to: ["ada@example.com"] }, + ); + expect(Buffer.from(raw, "base64url").toString()).toContain("To: ada@example.com"); + }); + + test("applies and removes labels on messages and threads", async () => { + const fetchImpl = createFetchImpl(async (input, init) => { + const url = new URL(String(input)); + const request = jsonRequestBody(init); + expect(url.pathname).toMatch(/\/(messages|threads)\/(message-1|thread-1)\/modify$/); + if (url.pathname.includes("threads")) { + return new Response(JSON.stringify({ id: "thread-1", messages: [] })); + } + return new Response( + JSON.stringify({ + id: "message-1", + labelIds: request.addLabelIds ?? request.removeLabelIds, + }), + ); + }); + const tools = createGmailTools({ capabilities: testCapabilities(fetchImpl) }); + const signal = new AbortController().signal; + + for (const name of [ + "gmail_label_message", + "gmail_unlabel_message", + "gmail_label_thread", + "gmail_unlabel_thread", + ]) { + const isThread = name.endsWith("thread"); + const result = await tools.run( + { + id: name, + name, + arguments: { [isThread ? "threadId" : "messageId"]: isThread ? "thread-1" : "message-1", labelIds: ["STARRED"] }, + }, + signal, + ); + expect(result.isError).not.toBe(true); + } + await tools.dispose(); + }); +}); diff --git a/src/tools/create-tools.ts b/src/tools/create-tools.ts new file mode 100644 index 0000000..53c317c --- /dev/null +++ b/src/tools/create-tools.ts @@ -0,0 +1,458 @@ +import { ArkErrors, type Type } from "arktype"; +import type { + ToolCall, + ToolDefinition, + ToolResult, + ToolRunner, +} from "@intx/types/runtime"; +import type { HttpMediatedCredential } from "@intx/types"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { + createGmailClient, + type GmailClient, + type GmailDraft, + type GmailFetch, + type GmailFormat, +} from "../client/index.js"; +import { GMAIL_CREDENTIAL_HANDLE, TOOL_DEFINITIONS } from "./definitions.js"; +import { + metadataHeadersForView, + toToolDraft, + toThreadListMessage, + toToolMessage, + toToolThread, + type GmailMessageFormat, + type GmailThreadView, +} from "./models.js"; +import { createRawDraft, type ReplyContext } from "./drafts.js"; +import { + CreateDraftInput, + GetMessageInput, + GetThreadInput, + LabelMessageInput, + LabelThreadInput, + ListDraftsInput, + ListLabelsInput, + SearchThreadsInput, + UnlabelMessageInput, + UnlabelThreadInput, +} from "./schemas.js"; + +export type CreateGmailToolsOptions = { capabilities: RuntimeCapabilities }; + +export interface GmailTools extends ToolRunner { + readonly definitions: ToolDefinition[]; + dispose(): Promise; +} + +type GmailToolHandler = ( + client: GmailClient, + call: ToolCall, + signal: AbortSignal, +) => Promise; + +function parseToolInput( + validator: T, + input: unknown, +): T["infer"] { + const result = validator(input); + if (result instanceof ArkErrors) { + throw new Error(`invalid tool input: ${result.summary}`); + } + return result; +} + +function rejectUnsupportedAttachments(attachments: readonly unknown[] | undefined): void { + if (attachments !== undefined && attachments.length > 0) { + throw new Error("gmail_create_draft: attachments are not supported by Gmail MCP drafts"); + } +} + +function toGmailFormat(format: GmailMessageFormat): GmailFormat { + switch (format) { + case "FULL_CONTENT": + return "full"; + case "METADATA_ONLY": + return "metadata"; + case "MINIMAL": + return "metadata"; + } +} + +function metadataHeadersForFormat(format: GmailMessageFormat): readonly string[] | undefined { + if (format === "METADATA_ONLY") return undefined; + return ["Subject", "From", "To", "Cc", "Date"]; +} + +function draftFormat(view: "DRAFT_VIEW_FULL" | "DRAFT_VIEW_METADATA_ONLY"): GmailMessageFormat { + return view === "DRAFT_VIEW_FULL" ? "FULL_CONTENT" : "METADATA_ONLY"; +} + +function headerValue( + message: { payload?: { headers?: Array<{ name: string; value: string }> } }, + name: string, +): string | undefined { + return message.payload?.headers?.find( + (header) => header.name.toLowerCase() === name.toLowerCase(), + )?.value; +} + +function plainEmailAddress(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const angleAddress = /<([^<>]+)>/.exec(value)?.[1]; + return (angleAddress ?? value).trim(); +} + +async function replyContext( + client: GmailClient, + messageId: string | undefined, + signal: AbortSignal, +): Promise { + if (messageId === undefined) return undefined; + const original = await client.getMessage(messageId, { format: "full", signal }); + const normalized = toToolMessage(original, "FULL_CONTENT"); + const originalMessageId = headerValue(original, "Message-ID"); + const originalReferences = headerValue(original, "References"); + const recipient = inputRecipients(original, normalized); + return { + ...(original.threadId === undefined ? {} : { threadId: original.threadId }), + ...(originalMessageId === undefined ? {} : { messageId: originalMessageId }), + ...(originalReferences === undefined ? {} : { references: originalReferences }), + ...(normalized.subject === undefined ? {} : { subject: normalized.subject }), + ...(normalized.plaintextBody === undefined + ? {} + : { plaintextBody: normalized.plaintextBody }), + ...(normalized.htmlBody === undefined ? {} : { htmlBody: normalized.htmlBody }), + ...(recipient === undefined ? {} : { to: recipient }), + }; +} + +function inputRecipients( + original: { labelIds?: readonly string[] }, + normalized: ReturnType, +): readonly string[] | undefined { + const replyAddress = original.labelIds?.includes("SENT") + ? normalized.toRecipients?.[0] + : normalized.sender; + const recipient = plainEmailAddress(replyAddress); + return recipient === undefined || recipient.length === 0 ? undefined : [recipient]; +} + +async function listDraftsForQuery( + client: GmailClient, + query: string, + pageSize: number, + pageToken: string | undefined, + signal: AbortSignal, +): Promise<{ drafts: GmailDraft[]; nextPageToken?: string }> { + const matchingMessages = await client.listMessages({ + query: `in:drafts ${query}`, + pageSize, + pageToken, + signal, + }); + const draftsByMessageId = new Map(); + let draftsPageToken: string | undefined; + do { + const draftPage = await client.listDrafts({ + pageSize: 500, + pageToken: draftsPageToken, + signal, + }); + for (const draft of draftPage.drafts ?? []) { + if (draft.message?.id !== undefined) { + draftsByMessageId.set(draft.message.id, draft); + } + } + draftsPageToken = draftPage.nextPageToken; + } while (draftsPageToken !== undefined); + + const drafts = (matchingMessages.messages ?? []).flatMap((message) => { + const draft = draftsByMessageId.get(message.id); + return draft === undefined ? [] : [draft]; + }); + return { + drafts, + ...(matchingMessages.nextPageToken === undefined + ? {} + : { nextPageToken: matchingMessages.nextPageToken }), + }; +} + +const HANDLERS = new Map([ + [ + "gmail_search_threads", + async (client, call, signal) => { + const input = parseToolInput(SearchThreadsInput, call.arguments); + const response = await client.listThreads({ + query: input.query, + pageToken: input.pageToken, + pageSize: input.pageSize, + includeTrash: input.includeTrash, + signal, + }); + const threads = await Promise.all( + (response.threads ?? []).map(async (thread) => { + const fullThread = await client.getThread(thread.id, { + format: "metadata", + metadataHeaders: metadataHeadersForView(input.view), + signal, + }); + return { + id: fullThread.id, + messages: (fullThread.messages ?? []).map((message) => + toThreadListMessage(message, input.view), + ), + }; + }), + ); + return { + callId: call.id, + content: { + data: { + threads, + ...(response.nextPageToken === undefined + ? {} + : { nextPageToken: response.nextPageToken }), + ...(response.resultSizeEstimate === undefined + ? {} + : { resultCountEstimate: String(response.resultSizeEstimate) }), + }, + }, + }; + }, + ], + [ + "gmail_get_thread", + async (client, call, signal) => { + const input = parseToolInput(GetThreadInput, call.arguments); + const thread = await client.getThread(input.threadId, { + format: toGmailFormat(input.messageFormat), + metadataHeaders: metadataHeadersForFormat(input.messageFormat), + signal, + }); + return { + callId: call.id, + content: { data: toToolThread(thread, input.messageFormat) }, + }; + }, + ], + [ + "gmail_get_message", + async (client, call, signal) => { + const input = parseToolInput(GetMessageInput, call.arguments); + const message = await client.getMessage(input.messageId, { + format: toGmailFormat(input.messageFormat), + metadataHeaders: metadataHeadersForFormat(input.messageFormat), + signal, + }); + return { + callId: call.id, + content: { data: toToolMessage(message, input.messageFormat) }, + }; + }, + ], + [ + "gmail_list_labels", + async (client, call, signal) => { + parseToolInput(ListLabelsInput, call.arguments); + const response = await client.listLabels(signal); + return { + callId: call.id, + content: { data: { labels: response.labels ?? [] } }, + }; + }, + ], + [ + "gmail_create_draft", + async (client, call, signal) => { + const input = parseToolInput(CreateDraftInput, call.arguments); + rejectUnsupportedAttachments(input.attachments); + const { attachments: _attachments, replyToMessageId, ...draft } = input; + const reply = await replyContext(client, replyToMessageId, signal); + const created = await client.createDraft({ + raw: createRawDraft(draft, reply), + ...(reply?.threadId === undefined ? {} : { threadId: reply.threadId }), + signal, + }); + return { + callId: call.id, + content: { data: toToolDraft(created, "FULL_CONTENT") }, + }; + }, + ], + [ + "gmail_list_drafts", + async (client, call, signal) => { + const input = parseToolInput(ListDraftsInput, call.arguments); + const format = draftFormat(input.view); + const response = + input.query === undefined + ? await client.listDrafts({ + pageSize: input.pageSize, + pageToken: input.pageToken, + signal, + }) + : await listDraftsForQuery( + client, + input.query, + input.pageSize, + input.pageToken, + signal, + ); + const drafts = await Promise.all( + (response.drafts ?? []).map(async (draft) => + toToolDraft( + await client.getDraft(draft.id, { + format: toGmailFormat(format), + metadataHeaders: metadataHeadersForFormat(format), + signal, + }), + format, + ), + ), + ); + return { + callId: call.id, + content: { + data: { + drafts, + ...(response.nextPageToken === undefined + ? {} + : { nextPageToken: response.nextPageToken }), + }, + }, + }; + }, + ], + [ + "gmail_label_message", + async (client, call, signal) => { + const input = parseToolInput(LabelMessageInput, call.arguments); + const message = await client.modifyMessage( + input.messageId, + { addLabelIds: input.labelIds }, + signal, + ); + return { + callId: call.id, + content: { data: toToolMessage(message, "METADATA_ONLY") }, + }; + }, + ], + [ + "gmail_unlabel_message", + async (client, call, signal) => { + const input = parseToolInput(UnlabelMessageInput, call.arguments); + const message = await client.modifyMessage( + input.messageId, + { removeLabelIds: input.labelIds }, + signal, + ); + return { + callId: call.id, + content: { data: toToolMessage(message, "METADATA_ONLY") }, + }; + }, + ], + [ + "gmail_label_thread", + async (client, call, signal) => { + const input = parseToolInput(LabelThreadInput, call.arguments); + const thread = await client.modifyThread( + input.threadId, + { addLabelIds: input.labelIds }, + signal, + ); + return { + callId: call.id, + content: { data: toToolThread(thread, "METADATA_ONLY") }, + }; + }, + ], + [ + "gmail_unlabel_thread", + async (client, call, signal) => { + const input = parseToolInput(UnlabelThreadInput, call.arguments); + const thread = await client.modifyThread( + input.threadId, + { removeLabelIds: input.labelIds }, + signal, + ); + return { + callId: call.id, + content: { data: toToolThread(thread, "METADATA_ONLY") }, + }; + }, + ], +]); + +export function createGmailTools(opts: CreateGmailToolsOptions): GmailTools { + assertCatalogMatchesHandlers(); + let clientPromise: Promise | undefined; + let mediated: HttpMediatedCredential | undefined; + let disposed = false; + + async function getClient(): Promise { + clientPromise ??= (async () => { + const credentials = opts.capabilities.resolve("credentials"); + const resolved = await credentials.resolve(GMAIL_CREDENTIAL_HANDLE); + if (resolved.kind !== "http") { + throw new Error( + `gmail-tools: expected http mediated credential for handle "${GMAIL_CREDENTIAL_HANDLE}", got ${resolved.kind}`, + ); + } + mediated = resolved; + const fetchImpl: GmailFetch = (input, init) => resolved.fetch(input, init); + return createGmailClient({ + fetchImpl, + }); + })(); + return clientPromise; + } + + return { + definitions: TOOL_DEFINITIONS, + async run(call: ToolCall, signal: AbortSignal): Promise { + const handler = HANDLERS.get(call.name); + if (handler === undefined) { + return { + callId: call.id, + content: { error: `Unknown tool: "${call.name}"` }, + isError: true, + }; + } + + try { + return await handler(await getClient(), call, signal); + } catch (error) { + return { + callId: call.id, + content: { + error: error instanceof Error ? error.message : String(error), + }, + isError: true, + }; + } + }, + async dispose() { + if (disposed) return; + disposed = true; + if (clientPromise !== undefined) { + await clientPromise.catch(() => undefined); + } + await mediated?.dispose(); + }, + }; +} + +function assertCatalogMatchesHandlers(): void { + const definitions = TOOL_DEFINITIONS.map((definition) => definition.name).sort(); + const handlers = [...HANDLERS.keys()].sort(); + if (JSON.stringify(definitions) !== JSON.stringify(handlers)) { + throw new Error( + `gmail-tools: definitions and handlers differ (${definitions.join(", ")} vs ${handlers.join(", ")})`, + ); + } +} diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts new file mode 100644 index 0000000..e531abc --- /dev/null +++ b/src/tools/definitions.ts @@ -0,0 +1,72 @@ +import type { ToolDefinition } from "@intx/types/runtime"; + +import { + CreateDraftInput, + createInputJSONSchema, + GetMessageInput, + GetThreadInput, + LabelMessageInput, + LabelThreadInput, + ListDraftsInput, + ListLabelsInput, + SearchThreadsInput, + UnlabelMessageInput, + UnlabelThreadInput, +} from "./schemas.js"; + +export const GMAIL_CREDENTIAL_HANDLE = "gmail-api"; + +export const TOOL_DEFINITIONS: ToolDefinition[] = [ + { + name: "gmail_search_threads", + description: + "Search the authenticated Gmail mailbox and return thread summaries with an optional next page token.", + inputSchema: createInputJSONSchema(SearchThreadsInput), + }, + { + name: "gmail_get_thread", + description: "Fetch one Gmail thread with selected message detail.", + inputSchema: createInputJSONSchema(GetThreadInput), + }, + { + name: "gmail_get_message", + description: "Fetch one Gmail message with selected detail.", + inputSchema: createInputJSONSchema(GetMessageInput), + }, + { + name: "gmail_list_labels", + description: "List labels available in the authenticated Gmail mailbox.", + inputSchema: createInputJSONSchema(ListLabelsInput), + }, + { + name: "gmail_create_draft", + description: + "Create a Gmail draft. Attachments are accepted by the schema for Google compatibility but are not supported by Gmail's MCP draft flow.", + inputSchema: createInputJSONSchema(CreateDraftInput), + }, + { + name: "gmail_list_drafts", + description: "List Gmail drafts with Gmail-query filtering and pagination.", + inputSchema: createInputJSONSchema(ListDraftsInput), + }, + { + name: "gmail_label_message", + description: "Add one or more labels to a Gmail message.", + inputSchema: createInputJSONSchema(LabelMessageInput), + }, + { + name: "gmail_unlabel_message", + description: "Remove one or more labels from a Gmail message.", + inputSchema: createInputJSONSchema(UnlabelMessageInput), + }, + { + name: "gmail_label_thread", + description: "Add one or more labels to every message in a Gmail thread.", + inputSchema: createInputJSONSchema(LabelThreadInput), + }, + { + name: "gmail_unlabel_thread", + description: "Remove one or more labels from every message in a Gmail thread.", + inputSchema: createInputJSONSchema(UnlabelThreadInput), + }, +]; diff --git a/src/tools/drafts.ts b/src/tools/drafts.ts new file mode 100644 index 0000000..13ef6f8 --- /dev/null +++ b/src/tools/drafts.ts @@ -0,0 +1,121 @@ +export type DraftInput = { + to?: readonly string[]; + cc?: readonly string[]; + bcc?: readonly string[]; + subject?: string; + body?: string; + htmlBody?: string; +}; + +export type ReplyContext = { + threadId?: string; + messageId?: string; + references?: string; + subject?: string; + plaintextBody?: string; + htmlBody?: string; + to?: readonly string[]; +}; + +function assertSafeHeader(value: string, name: string): void { + if (value.includes("\r") || value.includes("\n")) { + throw new Error(`argument "${name}" cannot contain a newline`); + } +} + +function recipientHeader(name: string, addresses: readonly string[] | undefined): string[] { + if (addresses === undefined || addresses.length === 0) return []; + for (const address of addresses) { + assertSafeHeader(address, name); + } + return [`${name}: ${addresses.join(", ")}`]; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function replyBodies(input: DraftInput, reply: ReplyContext | undefined): { + plaintext: string; + html: string | undefined; +} { + const body = input.body ?? ""; + const html = input.htmlBody; + if (reply === undefined) { + return { plaintext: body, html }; + } + + const quotedPlaintext = reply.plaintextBody; + const plaintext = + quotedPlaintext === undefined || quotedPlaintext.length === 0 + ? body + : `${body}\r\n\r\n${quotedPlaintext}`; + const quotedHtml = reply.htmlBody; + const richBody = html ?? (body.length === 0 ? undefined : `

${escapeHtml(body)}

`); + const mergedHtml = + richBody === undefined && quotedHtml === undefined + ? undefined + : `${richBody ?? ""}${quotedHtml === undefined ? "" : `
${quotedHtml}
`}`; + return { plaintext, html: mergedHtml }; +} + +function encodeBase64Url(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); +} + +export function createRawDraft(input: DraftInput, reply?: ReplyContext): string { + const subject = input.subject ?? reply?.subject ?? ""; + assertSafeHeader(subject, "subject"); + const headers = [ + ...recipientHeader("To", input.to ?? reply?.to), + ...recipientHeader("Cc", input.cc), + ...recipientHeader("Bcc", input.bcc), + `Subject: ${subject}`, + "MIME-Version: 1.0", + ]; + if (reply?.messageId !== undefined) { + assertSafeHeader(reply.messageId, "replyToMessageId"); + if (reply.references !== undefined) { + assertSafeHeader(reply.references, "references"); + } + const references = + reply.references === undefined + ? reply.messageId + : `${reply.references} ${reply.messageId}`; + headers.push(`In-Reply-To: ${reply.messageId}`); + headers.push(`References: ${references}`); + } + + const bodies = replyBodies(input, reply); + if (bodies.html === undefined) { + return encodeBase64Url( + `${headers.join("\r\n")}\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n${bodies.plaintext}`, + ); + } + + const boundary = `gmail-tools-${crypto.randomUUID()}`; + return encodeBase64Url( + [ + ...headers, + `Content-Type: multipart/alternative; boundary="${boundary}"`, + "", + `--${boundary}`, + "Content-Type: text/plain; charset=UTF-8", + "", + bodies.plaintext, + `--${boundary}`, + "Content-Type: text/html; charset=UTF-8", + "", + bodies.html, + `--${boundary}--`, + ].join("\r\n"), + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..aa6cfc9 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,2 @@ +export * from "./create-tools.js"; +export * from "./definitions.js"; diff --git a/src/tools/models.ts b/src/tools/models.ts new file mode 100644 index 0000000..99292a0 --- /dev/null +++ b/src/tools/models.ts @@ -0,0 +1,251 @@ +import type { + GmailDraft, + GmailHeader, + GmailMessage, + GmailMessagePart, + GmailThread, +} from "../client/index.js"; + +export type GmailMessageFormat = "FULL_CONTENT" | "METADATA_ONLY" | "MINIMAL"; +export type GmailThreadView = + | "THREAD_VIEW_METADATA_ONLY" + | "THREAD_VIEW_MINIMAL"; + +export type GmailAttachment = { + id: string; + mimeType?: string; + filename?: string; +}; + +export type GmailToolMessage = { + id: string; + threadId?: string; + labelIds: string[]; + sizeEstimate?: number; + date?: string; + snippet?: string; + subject?: string; + sender?: string; + toRecipients?: string[]; + ccRecipients?: string[]; + bccRecipients?: string[]; + plaintextBody?: string; + htmlBody?: string; + attachmentIds?: string[]; + attachments?: GmailAttachment[]; +}; + +export type GmailToolThread = { + id: string; + messages: GmailToolMessage[]; +}; + +export type GmailToolDraft = Omit< + GmailToolMessage, + "id" | "labelIds" | "sizeEstimate" | "snippet" | "sender" | "attachmentIds" | "attachments" +> & { + id: string; +}; + +const THREAD_METADATA_HEADERS: readonly string[] = ["From", "To", "Cc", "Date"]; +const THREAD_MINIMAL_HEADERS: readonly string[] = ["Subject", ...THREAD_METADATA_HEADERS]; + +function headersByName(headers: GmailHeader[] | undefined): Map { + const result = new Map(); + for (const header of headers ?? []) { + const name = header.name.trim().toLowerCase(); + if (name.length > 0 && !result.has(name)) result.set(name, header.value); + } + return result; +} + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return new TextDecoder().decode( + Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)), + ); +} + +function collectParts(part: GmailMessagePart | undefined): GmailMessagePart[] { + if (part === undefined) return []; + return [part, ...(part.parts ?? []).flatMap(collectParts)]; +} + +function bodyForMimeType(parts: GmailMessagePart[], mimeType: string): string | undefined { + const values = parts.flatMap((part) => { + if ( + part.mimeType !== mimeType || + part.body?.data === undefined || + part.body.attachmentId !== undefined || + (part.filename !== undefined && part.filename.length > 0) + ) { + return []; + } + return [decodeBase64Url(part.body.data)]; + }); + return values.length === 0 ? undefined : values.join("\n\n"); +} + +function recipients(value: string | undefined): string[] | undefined { + if (value === undefined || value.length === 0) return undefined; + const result: string[] = []; + let start = 0; + let angleDepth = 0; + let quoted = false; + let escaped = false; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (escaped) { + escaped = false; + continue; + } + if (character === "\\" && quoted) { + escaped = true; + continue; + } + if (character === '"') { + quoted = !quoted; + continue; + } + if (quoted) continue; + if (character === "<") { + angleDepth += 1; + } else if (character === ">" && angleDepth > 0) { + angleDepth -= 1; + } else if (character === "," && angleDepth === 0) { + result.push(value.slice(start, index).trim()); + start = index + 1; + } + } + result.push(value.slice(start).trim()); + return result; +} + +function dateFromMessage(message: GmailMessage, headerDate: string | undefined): string | undefined { + const source = headerDate ?? message.internalDate; + if (source === undefined) return undefined; + const timestamp = headerDate === undefined ? Number(source) : Date.parse(source); + if (!Number.isFinite(timestamp)) return undefined; + return new Date(timestamp).toISOString().slice(0, 10); +} + +function baseMessage(message: GmailMessage): GmailToolMessage { + const headers = headersByName(message.payload?.headers); + return { + id: message.id, + ...(message.threadId === undefined ? {} : { threadId: message.threadId }), + labelIds: message.labelIds ?? [], + ...(message.sizeEstimate === undefined ? {} : { sizeEstimate: message.sizeEstimate }), + ...(dateFromMessage(message, headers.get("date")) === undefined + ? {} + : { date: dateFromMessage(message, headers.get("date")) }), + }; +} + +function messageMetadata(message: GmailMessage, includeSubject: boolean): GmailToolMessage { + const headers = headersByName(message.payload?.headers); + return { + ...baseMessage(message), + ...(includeSubject && message.snippet !== undefined ? { snippet: message.snippet } : {}), + ...(includeSubject && headers.get("subject") !== undefined + ? { subject: headers.get("subject") } + : {}), + ...(headers.get("from") === undefined ? {} : { sender: headers.get("from") }), + ...(recipients(headers.get("to")) === undefined + ? {} + : { toRecipients: recipients(headers.get("to")) }), + ...(recipients(headers.get("cc")) === undefined + ? {} + : { ccRecipients: recipients(headers.get("cc")) }), + ...(recipients(headers.get("bcc")) === undefined + ? {} + : { bccRecipients: recipients(headers.get("bcc")) }), + }; +} + +function attachments(parts: GmailMessagePart[]): GmailAttachment[] { + return parts.flatMap((part) => { + const id = part.body?.attachmentId; + if (id === undefined) return []; + return [ + { + id, + ...(part.mimeType === undefined ? {} : { mimeType: part.mimeType }), + ...(part.filename === undefined || part.filename.length === 0 + ? {} + : { filename: part.filename }), + }, + ]; + }); +} + +export function metadataHeadersForView(view: GmailThreadView): readonly string[] { + return view === "THREAD_VIEW_MINIMAL" + ? THREAD_MINIMAL_HEADERS + : THREAD_METADATA_HEADERS; +} + +export function toToolMessage( + message: GmailMessage, + format: GmailMessageFormat, +): GmailToolMessage { + if (format === "METADATA_ONLY") return baseMessage(message); + + const metadata = messageMetadata(message, true); + if (format === "MINIMAL") return metadata; + + const parts = collectParts(message.payload); + const messageAttachments = attachments(parts); + return { + ...metadata, + ...(bodyForMimeType(parts, "text/plain") === undefined + ? {} + : { plaintextBody: bodyForMimeType(parts, "text/plain") }), + ...(bodyForMimeType(parts, "text/html") === undefined + ? {} + : { htmlBody: bodyForMimeType(parts, "text/html") }), + ...(messageAttachments.length === 0 + ? {} + : { + attachmentIds: messageAttachments.map((attachment) => attachment.id), + attachments: messageAttachments, + }), + }; +} + +export function toToolThread( + thread: GmailThread, + format: GmailMessageFormat, +): GmailToolThread { + return { + id: thread.id, + messages: (thread.messages ?? []).map((message) => toToolMessage(message, format)), + }; +} + +export function toToolDraft( + draft: GmailDraft, + format: GmailMessageFormat, +): GmailToolDraft { + if (draft.message === undefined) return { id: draft.id }; + const { + id: _messageId, + labelIds: _labelIds, + sizeEstimate: _sizeEstimate, + snippet: _snippet, + sender: _sender, + attachmentIds: _attachmentIds, + attachments: _attachments, + ...message + } = toToolMessage(draft.message, format); + return { id: draft.id, ...message }; +} + +export function toThreadListMessage( + message: GmailMessage, + view: GmailThreadView, +): GmailToolMessage { + if (view === "THREAD_VIEW_MINIMAL") return toToolMessage(message, "MINIMAL"); + return messageMetadata(message, false); +} diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts new file mode 100644 index 0000000..e7524c6 --- /dev/null +++ b/src/tools/schemas.ts @@ -0,0 +1,133 @@ +import { type } from "arktype"; + +const DraftAttachmentInput = type({ + "+": "reject", + "id?": type("string").describe("External attachment ID."), + "filename?": type("string").describe("Attachment filename."), + "mimeType?": type("string").describe("IANA media type."), + content: type("string").describe("Base64-encoded file content."), + "inline?": type("boolean").describe("Whether the attachment is inline."), +}); + +export const SearchThreadsInput = type({ + "+": "reject", + "query?": type("string").describe( + "Gmail search query, for example from:alice@example.com.", + ), + pageSize: type("1 <= number.integer <= 50") + .describe("Maximum number of threads to return; defaults to 20.") + .default(20), + "pageToken?": type("string").describe( + "Gmail page token returned by a previous search.", + ), + includeTrash: type("boolean") + .describe("Whether to include threads from Gmail Trash.") + .default(false), + view: type("'THREAD_VIEW_MINIMAL' | 'THREAD_VIEW_METADATA_ONLY'") + .describe("Controls the message fields returned with each thread.") + .default("THREAD_VIEW_MINIMAL"), +}); +export type SearchThreadsInput = typeof SearchThreadsInput.infer; + +export const GetThreadInput = type({ + "+": "reject", + threadId: type("string > 0").describe("Gmail thread ID."), + messageFormat: type("'FULL_CONTENT' | 'METADATA_ONLY' | 'MINIMAL'") + .describe("Controls the level of message detail returned.") + .default("FULL_CONTENT"), +}); +export type GetThreadInput = typeof GetThreadInput.infer; + +export const GetMessageInput = type({ + "+": "reject", + messageId: type("string > 0").describe("Gmail message ID."), + messageFormat: type("'FULL_CONTENT' | 'METADATA_ONLY' | 'MINIMAL'") + .describe("Controls the level of message detail returned.") + .default("FULL_CONTENT"), +}); +export type GetMessageInput = typeof GetMessageInput.infer; + +export const ListLabelsInput = type({ "+": "reject" }); +export type ListLabelsInput = typeof ListLabelsInput.infer; + +export const CreateDraftInput = type({ + "+": "reject", + "to?": type("string[]").describe( + "Primary recipient addresses, including RFC 5322 display-name forms.", + ), + "cc?": type("string[]").describe( + "Carbon-copy recipient addresses, including RFC 5322 display-name forms.", + ), + "bcc?": type("string[]").describe( + "Blind-carbon-copy recipient addresses, including RFC 5322 display-name forms.", + ), + "subject?": type("string").describe("Draft subject; defaults to empty."), + "body?": type("string").describe("Plain-text draft body."), + "htmlBody?": type("string").describe("Rich-text HTML draft body."), + "replyToMessageId?": type("string > 0").describe( + "Message ID to reply to in the same Gmail thread.", + ), + "attachments?": DraftAttachmentInput.array().describe( + "Reserved for Google compatibility; Gmail MCP drafts do not currently support attachments.", + ), +}); +export type CreateDraftInput = typeof CreateDraftInput.infer; + +export const ListDraftsInput = type({ + "+": "reject", + pageSize: type("1 <= number.integer <= 50") + .describe("Maximum number of drafts to return; defaults to 20.") + .default(20), + "pageToken?": type("string").describe("Page token from a previous list."), + "query?": type("string").describe("Gmail draft search query."), + view: type("'DRAFT_VIEW_FULL' | 'DRAFT_VIEW_METADATA_ONLY'") + .describe("Controls whether draft subject and bodies are returned.") + .default("DRAFT_VIEW_FULL"), +}); +export type ListDraftsInput = typeof ListDraftsInput.infer; + +const LabelIds = type("string[] > 0").describe( + "Gmail system or user label IDs.", +); + +export const LabelMessageInput = type({ + "+": "reject", + messageId: type("string > 0").describe("Gmail message ID."), + labelIds: LabelIds, +}); +export type LabelMessageInput = typeof LabelMessageInput.infer; + +export const UnlabelMessageInput = type({ + "+": "reject", + messageId: type("string > 0").describe("Gmail message ID."), + labelIds: LabelIds, +}); +export type UnlabelMessageInput = typeof UnlabelMessageInput.infer; + +export const LabelThreadInput = type({ + "+": "reject", + threadId: type("string > 0").describe("Gmail thread ID."), + labelIds: LabelIds, +}); +export type LabelThreadInput = typeof LabelThreadInput.infer; + +export const UnlabelThreadInput = type({ + "+": "reject", + threadId: type("string > 0").describe("Gmail thread ID."), + labelIds: LabelIds, +}); +export type UnlabelThreadInput = typeof UnlabelThreadInput.infer; + +export function createInputJSONSchema(input: { + toJsonSchema: (options: { target: "draft-07" }) => unknown; +}): Record { + const schema = input.toJsonSchema({ target: "draft-07" }); + if (!isRecord(schema)) { + throw new Error("tool input schema must be a JSON object"); + } + return schema; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/tests/live/credential.test.ts b/tests/live/credential.test.ts new file mode 100644 index 0000000..90dead1 --- /dev/null +++ b/tests/live/credential.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { createStandaloneGmailCredential } from "./credential.js"; +import { type GmailOAuthConfig } from "./oauth.js"; + +const config: GmailOAuthConfig = { + clientId: "client-id", + clientSecret: "client-secret", + tokenFile: ".local/test-token.json", + port: 8765, + scopes: [], +}; + +describe("standalone Gmail credential", () => { + test("refreshes locally and pins requests to Gmail", async () => { + let tokenRequests = 0; + let gmailRequests = 0; + const credential = createStandaloneGmailCredential({ + config, + token: { refreshToken: "refresh-token" }, + fetchImpl: (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://oauth2.googleapis.com/token") { + tokenRequests += 1; + expect(init?.method).toBe("POST"); + return new Response( + JSON.stringify({ access_token: `access-${String(tokenRequests)}`, expires_in: 3_600 }), + { status: 200 }, + ); + } + gmailRequests += 1; + expect(url).toBe("https://gmail.googleapis.com/gmail/v1/users/me/labels"); + const headers = input instanceof Request ? input.headers : init?.headers; + expect(new Headers(headers).get("authorization")).toBe("Bearer access-1"); + return new Response(JSON.stringify({ labels: [] }), { status: 200 }); + }), + }); + + await expect( + credential.fetch("https://gmail.googleapis.com/gmail/v1/users/me/labels"), + ).resolves.toBeInstanceOf(Response); + await expect( + credential.fetch("https://gmail.googleapis.com/gmail/v1/users/me/labels"), + ).resolves.toBeInstanceOf(Response); + expect(tokenRequests).toBe(1); + expect(gmailRequests).toBe(2); + }); + + test("rejects non-Gmail origins before network access", async () => { + const credential = createStandaloneGmailCredential({ + config, + token: { refreshToken: "refresh-token" }, + fetchImpl: (async () => { + throw new Error("network should not run"); + }), + }); + await expect(credential.fetch("https://evil.example/steal")).rejects.toThrow( + /non-Gmail origin/, + ); + }); +}); diff --git a/tests/live/credential.ts b/tests/live/credential.ts new file mode 100644 index 0000000..761164e --- /dev/null +++ b/tests/live/credential.ts @@ -0,0 +1,84 @@ +import type { HttpMediatedCredential } from "@intx/types"; + +import type { GmailFetch } from "../../src/client/index.js"; +import { + refreshGmailAccessToken, + type GmailAccessToken, + type GmailOAuthConfig, +} from "./oauth.js"; +import type { StoredGmailToken } from "./token-store.js"; + +export const GMAIL_API_ORIGIN = "https://gmail.googleapis.com"; + +export type StandaloneGmailCredentialOptions = { + config: GmailOAuthConfig; + token: StoredGmailToken; + fetchImpl?: GmailFetch; +}; + +export function createStandaloneGmailCredential( + options: StandaloneGmailCredentialOptions, +): HttpMediatedCredential { + const networkFetch = options.fetchImpl ?? globalThis.fetch; + let accessToken: GmailAccessToken | undefined; + let refreshPromise: Promise | undefined; + + async function getAccessToken(force = false): Promise { + if ( + !force && + accessToken !== undefined && + accessToken.expiresAt > Date.now() + 60_000 + ) { + return accessToken.accessToken; + } + refreshPromise ??= refreshGmailAccessToken( + options.config, + options.token.refreshToken, + networkFetch, + ).finally(() => { + refreshPromise = undefined; + }); + accessToken = await refreshPromise; + return accessToken.accessToken; + } + + async function authenticatedFetch( + input: string | URL | Request, + init?: RequestInit, + forceRefresh = false, + ): Promise { + const sourceRequest = input instanceof Request ? input : undefined; + const url = new URL(sourceRequest?.url ?? String(input)); + if (url.origin !== GMAIL_API_ORIGIN) { + throw new Error( + `standalone Gmail credential rejected non-Gmail origin: ${url.origin}`, + ); + } + const headers = new Headers(sourceRequest?.headers); + new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); + headers.set("Authorization", `Bearer ${await getAccessToken(forceRefresh)}`); + + return networkFetch( + new Request(sourceRequest ?? url, { + ...init, + headers, + redirect: "manual", + }), + ); + } + + return { + kind: "http", + async fetch(input, init) { + const response = await authenticatedFetch(input, init); + const method = (init?.method ?? (input instanceof Request ? input.method : "GET")).toUpperCase(); + if (response.status !== 401 || !["GET", "HEAD", "OPTIONS"].includes(method)) { + return response; + } + return authenticatedFetch(input, init, true); + }, + dispose() { + accessToken = undefined; + }, + }; +} diff --git a/tests/live/fixtures.test.ts b/tests/live/fixtures.test.ts new file mode 100644 index 0000000..27a815f --- /dev/null +++ b/tests/live/fixtures.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from "bun:test"; + +import type { GmailTools } from "../../src/tools/create-tools.js"; +import { findGmailFixture } from "./fixtures.js"; + +test("findGmailFixture uses the Gmail tool argument contract", async () => { + let call: + | { id: string; name: string; arguments: Record } + | undefined; + const tools: GmailTools = { + definitions: [], + async dispose() {}, + async run(toolCall) { + call = toolCall; + return { + callId: toolCall.id, + content: { + data: { + threads: [{ id: "thread-1", messages: [{ id: "message-1" }] }], + }, + }, + }; + }, + }; + + await expect( + findGmailFixture(tools, "subject:(interchange-gmail-e2e-fixture)", new AbortController().signal), + ).resolves.toEqual({ threadId: "thread-1", messageId: "message-1" }); + expect(call).toEqual({ + id: "live-fixture-search", + name: "gmail_search_threads", + arguments: { query: "subject:(interchange-gmail-e2e-fixture)", pageSize: 10 }, + }); +}); + +test("findGmailFixture rejects ambiguous thread matches", async () => { + const tools: GmailTools = { + definitions: [], + async dispose() {}, + async run() { + return { + callId: "fixture-search", + content: { + data: { + threads: [ + { id: "thread-1", messages: [{ id: "message-1" }] }, + { id: "thread-2", messages: [{ id: "message-2" }] }, + ], + }, + }, + }; + }, + }; + + await expect( + findGmailFixture(tools, "subject:ambiguous", new AbortController().signal), + ).rejects.toThrow("exactly one Gmail thread"); +}); diff --git a/tests/live/fixtures.ts b/tests/live/fixtures.ts new file mode 100644 index 0000000..8bf3c48 --- /dev/null +++ b/tests/live/fixtures.ts @@ -0,0 +1,49 @@ +import type { GmailTools } from "../../src/tools/create-tools.js"; +import { getArray, getString, isRecord } from "./json.js"; + +export type GmailFixture = { threadId: string; messageId: string }; + +export async function findGmailFixture( + tools: GmailTools, + query: string, + signal: AbortSignal, +): Promise { + const result = await tools.run( + { + id: "live-fixture-search", + name: "gmail_search_threads", + arguments: { query, pageSize: 10 }, + }, + signal, + ); + if (result.isError) { + throw new Error(`fixture search failed: ${JSON.stringify(result.content)}`); + } + if (!isRecord(result.content) || !isRecord(result.content.data)) { + throw new Error("fixture search did not return structured data"); + } + const threads = getArray(result.content.data, "threads"); + if (threads === undefined || threads.length !== 1) { + throw new Error(`fixture query must match exactly one Gmail thread: ${query}`); + } + const thread = threads[0]; + if (!isRecord(thread)) { + throw new Error(`no Gmail fixture matched query: ${query}`); + } + const threadId = getString(thread, "id"); + const messages = getArray(thread, "messages"); + if (messages === undefined || messages.length !== 1) { + throw new Error(`fixture thread must contain exactly one Gmail message: ${query}`); + } + const firstMessage = messages[0]; + const messageId = isRecord(firstMessage) ? getString(firstMessage, "id") : undefined; + if ( + typeof threadId !== "string" || + threadId.length === 0 || + typeof messageId !== "string" || + messageId.length === 0 + ) { + throw new Error(`no Gmail fixture matched query: ${query}`); + } + return { threadId, messageId }; +} diff --git a/tests/live/gmail.mutations.e2e.test.ts b/tests/live/gmail.mutations.e2e.test.ts new file mode 100644 index 0000000..4376455 --- /dev/null +++ b/tests/live/gmail.mutations.e2e.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { getArray, getString, isRecord } from "./json.js"; +import { + createLiveHarness, + toolData, +} from "./live-harness.js"; + +const LIVE_TEST_TIMEOUT_MS = 60_000; +const mutationEnabled = + process.env.GMAIL_LIVE_TEST === "1" && process.env.GMAIL_LIVE_MUTATION_TEST === "1"; + +if (!mutationEnabled) { + test.skip("Gmail mutations require GMAIL_LIVE_TEST=1 and GMAIL_LIVE_MUTATION_TEST=1", () => {}); +} else { + describe("standalone Gmail mutations", () => { + let harness: Awaited>; + const draftSubjects = new Set(); + + beforeAll(async () => { + harness = await createLiveHarness(); + }, 300_000); + + afterAll(async () => { + try { + for (const subject of draftSubjects) { + await harness.deleteDraftsForSubject(subject); + } + } finally { + await harness?.dispose(); + } + }); + + test("creates and cleans up a draft, then restores fixture labels", async () => { + const signal = new AbortController().signal; + const run = (name: string, arguments_: Record) => + harness.tools.run( + { id: `live-${name}`, name, arguments: arguments_ }, + signal, + ); + const subject = `interchange-gmail-live-${crypto.randomUUID()}`; + draftSubjects.add(subject); + + const draftResult = await run("gmail_create_draft", { + subject, + body: "Standalone Gmail live-test draft.", + }); + const draftId = getString(toolData(draftResult, "gmail_create_draft"), "id"); + if (draftId === undefined) throw new Error("gmail_create_draft did not return an ID"); + + const listDraftsResult = await run("gmail_list_drafts", { + query: `subject:${subject}`, + }); + const drafts = getArray(toolData(listDraftsResult, "gmail_list_drafts"), "drafts") ?? []; + expect( + drafts.some( + (draft) => isRecord(draft) && getString(draft, "id") === draftId, + ), + ).toBe(true); + + const originalMessage = await run("gmail_get_message", { + messageId: harness.fixture.messageId, + messageFormat: "METADATA_ONLY", + }); + const messageWasStarred = (getArray( + toolData(originalMessage, "gmail_get_message"), + "labelIds", + ) ?? []).includes("STARRED"); + + const originalThread = await run("gmail_get_thread", { + threadId: harness.fixture.threadId, + messageFormat: "METADATA_ONLY", + }); + const originalMessages = getArray( + toolData(originalThread, "gmail_get_thread"), + "messages", + ) ?? []; + const originalMessageData = originalMessages[0]; + const threadWasStarred = + isRecord(originalMessageData) && + (getArray(originalMessageData, "labelIds") ?? []).includes("STARRED"); + + try { + const labelMessage = await run("gmail_label_message", { + messageId: harness.fixture.messageId, + labelIds: ["STARRED"], + }); + expect( + (getArray(toolData(labelMessage, "gmail_label_message"), "labelIds") ?? []).includes( + "STARRED", + ), + ).toBe(true); + + const unlabelMessage = await run("gmail_unlabel_message", { + messageId: harness.fixture.messageId, + labelIds: ["STARRED"], + }); + expect( + (getArray(toolData(unlabelMessage, "gmail_unlabel_message"), "labelIds") ?? []).includes( + "STARRED", + ), + ).toBe(false); + + const labelThread = await run("gmail_label_thread", { + threadId: harness.fixture.threadId, + labelIds: ["STARRED"], + }); + expect( + (getArray(toolData(labelThread, "gmail_label_thread"), "messages") ?? []).every( + (message) => + isRecord(message) && + (getArray(message, "labelIds") ?? []).includes("STARRED"), + ), + ).toBe(true); + + const unlabelThread = await run("gmail_unlabel_thread", { + threadId: harness.fixture.threadId, + labelIds: ["STARRED"], + }); + expect( + (getArray(toolData(unlabelThread, "gmail_unlabel_thread"), "messages") ?? []).every( + (message) => + !isRecord(message) || + !(getArray(message, "labelIds") ?? []).includes("STARRED"), + ), + ).toBe(true); + } finally { + try { + await run( + messageWasStarred ? "gmail_label_message" : "gmail_unlabel_message", + { messageId: harness.fixture.messageId, labelIds: ["STARRED"] }, + ); + } finally { + await run( + threadWasStarred ? "gmail_label_thread" : "gmail_unlabel_thread", + { threadId: harness.fixture.threadId, labelIds: ["STARRED"] }, + ); + } + } + }, LIVE_TEST_TIMEOUT_MS); + }); +} diff --git a/tests/live/gmail.read.e2e.test.ts b/tests/live/gmail.read.e2e.test.ts new file mode 100644 index 0000000..3ed0422 --- /dev/null +++ b/tests/live/gmail.read.e2e.test.ts @@ -0,0 +1,58 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +import { createLiveHarness, toolData, toolId } from "./live-harness.js"; + +const LIVE_TEST_TIMEOUT_MS = 60_000; + +if (process.env.GMAIL_LIVE_TEST !== "1") { + test("standalone Gmail E2E requires GMAIL_LIVE_TEST=1", () => { + throw new Error( + "Set GMAIL_LIVE_TEST=1 and provide Gmail OAuth configuration to run the standalone live suite", + ); + }); +} else { + describe("standalone Gmail read smoke", () => { + let harness: Awaited>; + + beforeAll(async () => { + harness = await createLiveHarness(); + }, 300_000); + + afterAll(async () => { + await harness?.dispose(); + }); + + test("reads the named fixture through every read tool", async () => { + const signal = new AbortController().signal; + const threadResult = await harness.tools.run( + { + id: "live-thread-read", + name: "gmail_get_thread", + arguments: { threadId: harness.fixture.threadId }, + }, + signal, + ); + expect(toolId(threadResult, "gmail_get_thread")).toBe(harness.fixture.threadId); + + const messageResult = await harness.tools.run( + { + id: "live-message-read", + name: "gmail_get_message", + arguments: { + messageId: harness.fixture.messageId, + messageFormat: "METADATA_ONLY", + }, + }, + signal, + ); + expect(toolId(messageResult, "gmail_get_message")).toBe(harness.fixture.messageId); + + const labelsResult = await harness.tools.run( + { id: "live-label-list", name: "gmail_list_labels", arguments: {} }, + signal, + ); + const labels = toolData(labelsResult, "gmail_list_labels").labels; + expect(Array.isArray(labels)).toBe(true); + }, LIVE_TEST_TIMEOUT_MS); + }); +} diff --git a/tests/live/json.ts b/tests/live/json.ts new file mode 100644 index 0000000..e897140 --- /dev/null +++ b/tests/live/json.ts @@ -0,0 +1,19 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function getString( + value: Record, + key: string, +): string | undefined { + const candidate = value[key]; + return typeof candidate === "string" ? candidate : undefined; +} + +export function getArray( + value: Record, + key: string, +): unknown[] | undefined { + const candidate = value[key]; + return Array.isArray(candidate) ? candidate : undefined; +} diff --git a/tests/live/live-harness.ts b/tests/live/live-harness.ts new file mode 100644 index 0000000..ae7852a --- /dev/null +++ b/tests/live/live-harness.ts @@ -0,0 +1,113 @@ +import { createRuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { createGmailTools, type GmailTools } from "../../src/tools/create-tools.js"; +import { + createStandaloneGmailCredential, + GMAIL_API_ORIGIN, +} from "./credential.js"; +import { findGmailFixture, type GmailFixture } from "./fixtures.js"; +import { authorizeGmail, readGmailOAuthConfig } from "./oauth.js"; +import { getArray, getString, isRecord } from "./json.js"; +import { createFileTokenStore } from "./token-store.js"; + +export type LiveHarness = { + tools: GmailTools; + fixture: GmailFixture; + deleteDraftsForSubject(subject: string): Promise; + dispose(): Promise; +}; + +export async function createLiveHarness(): Promise { + const config = readGmailOAuthConfig(); + const tokenStore = createFileTokenStore(config.tokenFile); + const existingToken = await tokenStore.read(); + const token = existingToken ?? (await authorizeGmail(config)); + if (!token.grantedScopes?.includes(config.scopes[0] ?? "")) { + throw new Error("Gmail OAuth token did not record granted scopes"); + } + if (existingToken === undefined) await tokenStore.write(token); + + const credential = createStandaloneGmailCredential({ + config, + token, + }); + const tools = createGmailTools({ + capabilities: createRuntimeCapabilities({ + credentials: { + async resolve(handle) { + if (handle !== "gmail-api") { + throw new Error(`unknown standalone credential handle: ${handle}`); + } + return credential; + }, + }, + }), + }); + + const query = process.env.GMAIL_LIVE_FIXTURE_QUERY?.trim(); + if (query === undefined || query.length === 0) { + await tools.dispose(); + throw new Error("GMAIL_LIVE_FIXTURE_QUERY is required for the live Gmail suite"); + } + + try { + const fixture = await findGmailFixture(tools, query, new AbortController().signal); + return { + tools, + fixture, + deleteDraftsForSubject: (subject) => deleteDraftsForSubject(tools, credential, subject), + async dispose() { + await tools.dispose(); + }, + }; + } catch (cause) { + await tools.dispose(); + throw cause; + } +} + +async function deleteDraftsForSubject( + tools: GmailTools, + credential: { fetch(input: string, init?: RequestInit): Promise }, + subject: string, +): Promise { + const result = await tools.run( + { + id: "live-cleanup-drafts", + name: "gmail_list_drafts", + arguments: { query: `subject:${subject}` }, + }, + new AbortController().signal, + ); + const data = toolData(result, "gmail_list_drafts"); + const drafts = getArray(data, "drafts") ?? []; + const draftIds = drafts.flatMap((draft) => { + if (!isRecord(draft) || getString(draft, "subject") !== subject) return []; + const id = getString(draft, "id"); + return id === undefined ? [] : [id]; + }); + for (const draftId of draftIds) { + const response = await credential.fetch( + `${GMAIL_API_ORIGIN}/gmail/v1/users/me/drafts/${encodeURIComponent(draftId)}`, + { method: "DELETE" }, + ); + if (!response.ok) { + throw new Error(`failed to delete live test draft: HTTP ${String(response.status)}`); + } + } +} + +export function toolData(result: unknown, toolName: string): Record { + if (!isRecord(result) || result.isError === true || !isRecord(result.content)) { + throw new Error(`${toolName} failed`); + } + const data = result.content.data; + if (!isRecord(data)) throw new Error(`${toolName} did not return structured data`); + return data; +} + +export function toolId(result: unknown, toolName: string): string { + const id = getString(toolData(result, toolName), "id"); + if (id === undefined || id.length === 0) throw new Error(`${toolName} did not return an ID`); + return id; +} diff --git a/tests/live/oauth.test.ts b/tests/live/oauth.test.ts new file mode 100644 index 0000000..f555516 --- /dev/null +++ b/tests/live/oauth.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; + +import { readGmailOAuthConfig, refreshGmailAccessToken } from "./oauth.js"; + +describe("Gmail OAuth", () => { + test("normalizes the required configuration", () => { + expect( + readGmailOAuthConfig({ + GMAIL_LIVE_CLIENT_ID: " client-id ", + GMAIL_LIVE_CLIENT_SECRET: " secret ", + }), + ).toMatchObject({ + clientId: "client-id", + clientSecret: "secret", + port: 8765, + scopes: ["https://www.googleapis.com/auth/gmail.modify"], + }); + }); + + test("rejects missing client credentials", () => { + expect(() => readGmailOAuthConfig({})).toThrow("GMAIL_LIVE_CLIENT_ID"); + expect(() => + readGmailOAuthConfig({ GMAIL_LIVE_CLIENT_ID: "client-id" }), + ).toThrow("GMAIL_LIVE_CLIENT_SECRET"); + }); + + test("sends the client secret when refreshing a token", async () => { + let requestBody = ""; + const token = await refreshGmailAccessToken( + { clientId: "client-id", clientSecret: "secret" }, + "refresh-token", + async (_input, init) => { + requestBody = String(init?.body); + return new Response( + JSON.stringify({ access_token: "access-token", expires_in: 3_600 }), + ); + }, + ); + + expect(token.accessToken).toBe("access-token"); + expect(requestBody).toContain("client_id=client-id"); + expect(requestBody).toContain("client_secret=secret"); + }); + + test("preserves Google OAuth error codes", async () => { + await expect( + refreshGmailAccessToken( + { clientId: "client-id", clientSecret: "secret" }, + "refresh-token", + async () => new Response('{"error":"invalid_grant"}', { status: 400 }), + ), + ).rejects.toThrow("invalid_grant"); + }); +}); diff --git a/tests/live/oauth.ts b/tests/live/oauth.ts new file mode 100644 index 0000000..e2a39a6 --- /dev/null +++ b/tests/live/oauth.ts @@ -0,0 +1,228 @@ +import { randomUUID } from "node:crypto"; + +import type { GmailFetch } from "../../src/client/index.js"; +import { getString, isRecord } from "./json.js"; +import type { StoredGmailToken } from "./token-store.js"; + +export const GMAIL_LIVE_SCOPES: readonly string[] = [ + "https://www.googleapis.com/auth/gmail.modify", +]; + +const AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; + +export type GmailOAuthConfig = { + clientId: string; + clientSecret: string; + tokenFile: string; + port: number; + scopes: readonly string[]; +}; + +export type GmailAccessToken = { + accessToken: string; + expiresAt: number; +}; + +export type GmailLiveLogger = { info(message: string): void }; + +export const defaultGmailLiveLogger: GmailLiveLogger = { + info(message) { + process.stdout.write(`${message}\n`); + }, +}; + +export function readGmailOAuthConfig( + env: NodeJS.ProcessEnv = process.env, +): GmailOAuthConfig { + const clientId = env.GMAIL_LIVE_CLIENT_ID?.trim(); + const clientSecret = env.GMAIL_LIVE_CLIENT_SECRET?.trim(); + if (clientId === undefined || clientId.length === 0) { + throw new Error("GMAIL_LIVE_CLIENT_ID is required for the live Gmail suite"); + } + if (clientSecret === undefined || clientSecret.length === 0) { + throw new Error("GMAIL_LIVE_CLIENT_SECRET is required for the live Gmail suite"); + } + return { + clientId, + clientSecret, + tokenFile: env.GMAIL_LIVE_TOKEN_FILE?.trim() || ".local/gmail-live-token.json", + port: parseLivePort(env.GMAIL_LIVE_PORT), + scopes: GMAIL_LIVE_SCOPES, + }; +} + +function parseLivePort(value: string | undefined): number { + if (value === undefined || value.trim().length === 0) return 8765; + const port = Number(value); + if (!Number.isInteger(port) || port < 1024 || port > 65_535) { + throw new Error("GMAIL_LIVE_PORT must be an integer between 1024 and 65535"); + } + return port; +} + +async function parseTokenResponse(response: Response): Promise> { + const raw = await response.text(); + let body: Record | undefined; + try { + const parsed: unknown = JSON.parse(raw); + body = isRecord(parsed) ? parsed : undefined; + } catch { + body = undefined; + } + if (!response.ok) { + const errorCode = + (body === undefined ? undefined : getString(body, "error")) ?? + `HTTP ${String(response.status)}`; + throw new Error(`Google OAuth token request failed: ${errorCode}`); + } + if (body === undefined) { + throw new Error("Google OAuth token response was not a JSON object"); + } + return body; +} + +function tokenFromResponse(body: Record): GmailAccessToken { + const accessToken = getString(body, "access_token"); + const expiresIn = body.expires_in; + if (typeof accessToken !== "string" || accessToken.length === 0) { + throw new Error("Google OAuth token response did not include access_token"); + } + if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn)) { + throw new Error("Google OAuth token response did not include expires_in"); + } + return { + accessToken, + expiresAt: Date.now() + expiresIn * 1_000, + }; +} + +export async function refreshGmailAccessToken( + config: Pick, + refreshToken: string, + fetchImpl: GmailFetch = globalThis.fetch, +): Promise { + const body = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }); + const response = await fetchImpl(TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + return tokenFromResponse(await parseTokenResponse(response)); +} + +async function exchangeAuthorizationCode( + config: Pick, + code: string, + redirectUri: string, + fetchImpl: GmailFetch, +): Promise { + const body = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + grant_type: "authorization_code", + redirect_uri: redirectUri, + }); + const response = await fetchImpl(TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + const parsed = await parseTokenResponse(response); + const refreshToken = getString(parsed, "refresh_token"); + if (typeof refreshToken !== "string" || refreshToken.length === 0) { + throw new Error( + "Google OAuth did not return a refresh token; revoke the prior grant and authorize again", + ); + } + const scope = getString(parsed, "scope"); + return { + refreshToken, + ...(typeof scope === "string" + ? { grantedScopes: scope.split(" ").filter(Boolean) } + : {}), + }; +} + +export async function authorizeGmail( + config: GmailOAuthConfig, + fetchImpl: GmailFetch = globalThis.fetch, + logger: GmailLiveLogger = defaultGmailLiveLogger, +): Promise { + const state = randomUUID(); + let stopServer = (): void => undefined; + const code = Promise.withResolvers(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: config.port, + fetch(request) { + const requestUrl = new URL(request.url); + if (requestUrl.pathname !== "/oauth2callback") { + return new Response("Not found", { status: 404 }); + } + if (requestUrl.searchParams.get("state") !== state) { + stopServer(); + code.reject(new Error("Google OAuth state mismatch")); + return new Response("Invalid OAuth state", { status: 400 }); + } + const error = requestUrl.searchParams.get("error"); + if (error !== null) { + stopServer(); + code.reject(new Error(`Google OAuth authorization failed: ${error}`)); + return new Response("Google OAuth was denied", { status: 400 }); + } + const authorizationCode = requestUrl.searchParams.get("code"); + if (authorizationCode === null || authorizationCode.length === 0) { + stopServer(); + code.reject(new Error("Google OAuth callback did not include a code")); + return new Response("Missing authorization code", { status: 400 }); + } + stopServer(); + code.resolve(authorizationCode); + return new Response("Authorization complete. You can close this window.", { + headers: { "Content-Type": "text/html" }, + }); + }, + }); + stopServer = () => server.stop(); + + const redirectUri = `http://127.0.0.1:${String(server.port)}/oauth2callback`; + const authorizationUrl = new URL(AUTHORIZATION_ENDPOINT); + authorizationUrl.search = new URLSearchParams({ + client_id: config.clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: config.scopes.join(" "), + access_type: "offline", + prompt: "consent", + state, + }).toString(); + + const timer = setTimeout(() => { + stopServer(); + code.reject(new Error("timed out waiting for Google OAuth callback")); + }, 5 * 60 * 1_000); + logger.info(`Open this URL to authorize the Gmail live test:\n${authorizationUrl}`); + try { + const child = Bun.spawn(["open", authorizationUrl.toString()], { + stdout: "ignore", + stderr: "ignore", + }); + void child.exited; + } catch { + // The URL is already printed for environments without macOS `open`. + } + + const authorizationCode = await code.promise.finally(() => { + clearTimeout(timer); + stopServer(); + }); + + return exchangeAuthorizationCode(config, authorizationCode, redirectUri, fetchImpl); +} diff --git a/tests/live/token-store.ts b/tests/live/token-store.ts new file mode 100644 index 0000000..c4dd038 --- /dev/null +++ b/tests/live/token-store.ts @@ -0,0 +1,55 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +import { getArray, getString, isRecord } from "./json.js"; + +export type StoredGmailToken = { + refreshToken: string; + grantedScopes?: string[]; +}; + +export function createFileTokenStore(path: string) { + return { + async read() { + let raw: string; + try { + raw = await readFile(path, "utf8"); + } catch (error) { + if (isMissingFileError(error)) return undefined; + throw error; + } + try { + return parseStoredGmailToken(JSON.parse(raw)); + } catch (cause) { + throw new Error(`invalid Gmail token file: ${path}`, { cause }); + } + }, + async write(token: StoredGmailToken) { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(path, `${JSON.stringify(token, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await chmod(path, 0o600); + }, + }; +} + +function parseStoredGmailToken(value: unknown): StoredGmailToken { + if (!isRecord(value)) throw new Error("expected JSON object"); + const refreshToken = getString(value, "refreshToken"); + if (refreshToken === undefined || refreshToken.length === 0) { + throw new Error("refreshToken is required"); + } + const grantedScopes = getArray(value, "grantedScopes"); + const scopes = + grantedScopes?.filter((scope): scope is string => typeof scope === "string") ?? []; + if (grantedScopes !== undefined && scopes.length !== grantedScopes.length) { + throw new Error("grantedScopes must contain strings"); + } + return { refreshToken, ...(grantedScopes === undefined ? {} : { grantedScopes: scopes }) }; +} + +function isMissingFileError(error: unknown): boolean { + return isRecord(error) && error.code === "ENOENT"; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..7b4187d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"], + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index b2e7497..2914d81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,30 +1,16 @@ { "compilerOptions": { - // Environment setup & latest features - "lib": ["ESNext"], + "strict": true, "target": "ESNext", - "module": "Preserve", - "moduleDetection": "force", - "jsx": "react-jsx", - "allowJs": true, - "types": ["bun"], - - // Bundler mode + "module": "ESNext", "moduleResolution": "bundler", - "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, - "noEmit": true, - - // Best practices - "strict": true, "skipLibCheck": true, + "noEmit": true, "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, - - // Some stricter flags (disabled by default) - "noUnusedLocals": false, - "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false - } + "types": ["node", "@types/bun"] + }, + "include": ["src"] } diff --git a/tsconfig.live.json b/tsconfig.live.json new file mode 100644 index 0000000..05082ea --- /dev/null +++ b/tsconfig.live.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.typecheck.json", + "compilerOptions": { + "types": ["node", "@types/bun"] + }, + "include": ["src", "tests/live"] +} diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json new file mode 100644 index 0000000..fc8520e --- /dev/null +++ b/tsconfig.typecheck.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +}