From e6577549850920e3098b1fb564e9d0826b0ead3f Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Sun, 23 Aug 2026 21:18:50 +0545 Subject: [PATCH 1/5] Add the Gmail API client --- README.md | 26 ++- bun.lock | 30 +++- index.ts | 1 - package.json | 43 ++++- src/client/client.test.ts | 125 +++++++++++++ src/client/client.ts | 364 ++++++++++++++++++++++++++++++++++++++ src/client/errors.ts | 13 ++ src/client/index.ts | 2 + src/client/schemas.ts | 107 +++++++++++ src/index.ts | 1 + tsconfig.build.json | 14 ++ tsconfig.json | 26 +-- tsconfig.typecheck.json | 3 + 13 files changed, 720 insertions(+), 35 deletions(-) delete mode 100644 index.ts create mode 100644 src/client/client.test.ts create mode 100644 src/client/client.ts create mode 100644 src/client/errors.ts create mode 100644 src/client/index.ts create mode 100644 src/client/schemas.ts create mode 100644 src/index.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.typecheck.json diff --git a/README.md b/README.md index e982db5..caad42d 100644 --- a/README.md +++ b/README.md @@ -1 +1,25 @@ -Corbits Tools for Google +# @corbits/google-tools + +Google API clients, starting with Gmail. The Gmail client accepts an injected +`fetch` implementation and never adds authorization headers itself. + +## 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. + +## License + +LGPL-2.1-only. See [LICENSE](./LICENSE). diff --git a/bun.lock b/bun.lock index bfcc771..3c02fa6 100644 --- a/bun.lock +++ b/bun.lock @@ -4,23 +4,37 @@ "workspaces": { "": { "name": "@corbits/google-tools", - "devDependencies": { - "@types/bun": "latest", + "dependencies": { + "arktype": "^2.2.3", }, - "peerDependencies": { - "typescript": "^5", + "devDependencies": { + "@types/bun": "1.3.14", + "@types/node": "22.10.5", + "typescript": "5.9.3", }, }, }, "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=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - "@types/node": ["@types/node@26.2.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg=="], + "@types/node": ["@types/node@22.10.5", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "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=="], "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..db38a95 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,13 @@ "version": "0.1.0", "type": "module", "license": "LGPL-2.1-only", - "description": "Corbits Tools for Google", + "description": "Corbits Google API clients, starting with Gmail.", + "keywords": [ + "corbits", + "google", + "gmail", + "tools" + ], "author": "Corbits", "homepage": "https://github.com/corbitsdev/google-tools#readme", "repository": { @@ -13,11 +19,38 @@ "bugs": { "url": "https://github.com/corbitsdev/google-tools/issues" }, - "module": "index.ts", + "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" + } + }, + "files": [ + "dist", + "src", + "!src/**/*.test.ts", + "LICENSE", + "README.md" + ], + "sideEffects": false, + "scripts": { + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", + "build": "rm -rf dist && tsc -p tsconfig.build.json", + "prepack": "bun run build", + "test": "bun test src/client" + }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "1.3.14", + "@types/node": "22.10.5", + "typescript": "5.9.3" }, - "peerDependencies": { - "typescript": "^5" + "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..914b8c3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1 @@ +export * from "./client/index.js"; diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..b0f2715 --- /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/client", "src/index.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index b2e7497..be755ed 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/client", "src/index.ts"] } 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" +} From e956e223e5ac02a4e392dd9648822b4bde0cc562 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Sun, 23 Aug 2026 21:22:20 +0545 Subject: [PATCH 2/5] Add Interchange Gmail tools --- README.md | 8 +- bun.lock | 8 + package.json | 32 +- scripts/link-intx.ts | 34 ++ src/index.ts | 1 + src/shims/intx-agent.d.ts | 43 +++ src/sidecar-bundle.ts | 37 +++ src/tools/create-tools.test.ts | 553 +++++++++++++++++++++++++++++++++ src/tools/create-tools.ts | 465 +++++++++++++++++++++++++++ src/tools/definitions.ts | 72 +++++ src/tools/drafts.ts | 121 ++++++++ src/tools/index.ts | 2 + src/tools/models.ts | 251 +++++++++++++++ src/tools/schemas.ts | 133 ++++++++ tsconfig.build.json | 5 +- tsconfig.json | 3 +- tsconfig.typecheck.json | 7 +- 17 files changed, 1767 insertions(+), 8 deletions(-) create mode 100644 scripts/link-intx.ts create mode 100644 src/shims/intx-agent.d.ts create mode 100644 src/sidecar-bundle.ts create mode 100644 src/tools/create-tools.test.ts create mode 100644 src/tools/create-tools.ts create mode 100644 src/tools/definitions.ts create mode 100644 src/tools/drafts.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/models.ts create mode 100644 src/tools/schemas.ts diff --git a/README.md b/README.md index caad42d..be35fe1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # @corbits/google-tools -Google API clients, starting with Gmail. The Gmail client accepts an injected -`fetch` implementation and never adds authorization headers itself. +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 @@ -11,8 +12,11 @@ bun add github:corbitsdev/google-tools ## Working on it +Use a sibling Interchange checkout at `../interchange` to run the tool tests. + ```bash bun install +bun run link:intx bun run typecheck bun test bun run build diff --git a/bun.lock b/bun.lock index 3c02fa6..f9e5df7 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,14 @@ "@types/node": "22.10.5", "typescript": "5.9.3", }, + "peerDependencies": { + "@intx/agent": "*", + "@intx/types": "*", + }, + "optionalPeers": [ + "@intx/agent", + "@intx/types", + ], }, }, "packages": { diff --git a/package.json b/package.json index db38a95..2793b3a 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "version": "0.1.0", "type": "module", "license": "LGPL-2.1-only", - "description": "Corbits Google API clients, starting with Gmail.", + "description": "Corbits Google tools for Interchange, starting with Gmail.", "keywords": [ "corbits", "google", "gmail", - "tools" + "tools", + "interchange" ], "author": "Corbits", "homepage": "https://github.com/corbitsdev/google-tools#readme", @@ -29,8 +30,24 @@ "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", @@ -40,10 +57,19 @@ ], "sideEffects": false, "scripts": { + "link:intx": "bun scripts/link-intx.ts", "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", "build": "rm -rf dist && tsc -p tsconfig.build.json", "prepack": "bun run build", - "test": "bun test src/client" + "test": "bun test --conditions intx-src src" + }, + "peerDependencies": { + "@intx/agent": "*", + "@intx/types": "*" + }, + "peerDependenciesMeta": { + "@intx/agent": { "optional": true }, + "@intx/types": { "optional": true } }, "devDependencies": { "@types/bun": "1.3.14", diff --git a/scripts/link-intx.ts b/scripts/link-intx.ts new file mode 100644 index 0000000..9330cf5 --- /dev/null +++ b/scripts/link-intx.ts @@ -0,0 +1,34 @@ +import { existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const interchangeRoot = resolve(root, "../interchange"); +const sourceRoot = join(interchangeRoot, "node_modules/@intx"); +const destinationRoot = join(root, "node_modules/@intx"); +const packages: readonly string[] = [ + "agent", + "types", + "inference", + "log", + "mime", + "crypto", +]; + +if (!existsSync(sourceRoot)) { + console.warn(`link-intx: ${sourceRoot} not found — skip`); + process.exit(0); +} + +mkdirSync(destinationRoot, { recursive: true }); +for (const name of packages) { + const source = join(sourceRoot, name); + const destination = join(destinationRoot, name); + if (!existsSync(source)) { + console.warn(`link-intx: missing ${source} — skip`); + continue; + } + rmSync(destination, { recursive: true, force: true }); + symlinkSync(source, destination); + console.log(`link-intx: @intx/${name} -> ${source}`); +} diff --git a/src/index.ts b/src/index.ts index 914b8c3..318f449 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./client/index.js"; +export * from "./tools/index.js"; diff --git a/src/shims/intx-agent.d.ts b/src/shims/intx-agent.d.ts new file mode 100644 index 0000000..f86b0cf --- /dev/null +++ b/src/shims/intx-agent.d.ts @@ -0,0 +1,43 @@ +import type { + ToolCall, + ToolDefinition, + ToolResult, +} from "@intx/types/runtime"; + +export interface BaseEnv { + [key: string]: unknown; +} + +export interface ToolDeclaration { + readonly name: string; + readonly approval?: "ask"; +} + +export type ToolHandler = ( + call: ToolCall, + signal: AbortSignal, +) => Promise; + +export interface ToolBundle { + readonly definitions: readonly ToolDefinition[]; + run(call: ToolCall, signal: AbortSignal): Promise; + dispose?(): Promise; +} + +export type ToolFactory = ( + env: EnvReq, +) => ToolBundle; + +export type AnnotatedToolFactory = + ToolFactory & { + readonly id: string; + readonly requires: readonly string[]; + readonly definitions: readonly ToolDeclaration[]; + }; + +export function defineTool(opts: { + id: string; + requires?: readonly string[]; + definitions: readonly ToolDeclaration[]; + factory: ToolFactory; +}): AnnotatedToolFactory; diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts new file mode 100644 index 0000000..d4da3b1 --- /dev/null +++ b/src/sidecar-bundle.ts @@ -0,0 +1,37 @@ +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", +]); +const MUTATING_TOOL_APPROVAL: "ask" = "ask"; + +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: MUTATING_TOOL_APPROVAL } + : {}), + })), + factory: (env) => { + const tools = createGmailTools({ capabilities: env.capabilities }); + return { + definitions: tools.definitions, + run: (call, signal) => tools.run(call, signal), + dispose: () => tools.dispose(), + }; + }, +}); 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..c29153c --- /dev/null +++ b/src/tools/create-tools.ts @@ -0,0 +1,465 @@ +import type { ToolHandler } from "@intx/agent"; +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 DraftInput, 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 ToolHandlerFactory = (client: GmailClient) => ToolHandler; + +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 HANDLER_FACTORIES: ReadonlyMap = new Map< + string, + ToolHandlerFactory +>([ + [ + "gmail_search_threads", + (client) => async (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", + (client) => async (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", + (client) => async (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", + (client) => async (call, signal) => { + parseToolInput(ListLabelsInput, call.arguments); + const response = await client.listLabels(signal); + return { + callId: call.id, + content: { data: { labels: response.labels ?? [] } }, + }; + }, + ], + [ + "gmail_create_draft", + (client) => async (call, signal) => { + const input = parseToolInput(CreateDraftInput, call.arguments); + rejectUnsupportedAttachments(input.attachments); + const draft: DraftInput = { + ...(input.to === undefined ? {} : { to: input.to }), + ...(input.cc === undefined ? {} : { cc: input.cc }), + ...(input.bcc === undefined ? {} : { bcc: input.bcc }), + ...(input.subject === undefined ? {} : { subject: input.subject }), + ...(input.body === undefined ? {} : { body: input.body }), + ...(input.htmlBody === undefined ? {} : { htmlBody: input.htmlBody }), + }; + const reply = await replyContext(client, input.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", + (client) => async (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", + (client) => async (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", + (client) => async (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", + (client) => async (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", + (client) => async (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 factory = HANDLER_FACTORIES.get(call.name); + if (factory === undefined) { + return { + callId: call.id, + content: { error: `Unknown tool: "${call.name}"` }, + isError: true, + }; + } + + try { + return await factory(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 = [...HANDLER_FACTORIES.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/tsconfig.build.json b/tsconfig.build.json index b0f2715..67308b3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,6 +2,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "types": ["node"], + "paths": { + "@intx/agent": ["./src/shims/intx-agent.d.ts"] + }, "noEmit": false, "declaration": true, "declarationMap": true, @@ -9,6 +12,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/client", "src/index.ts"], + "include": ["src"], "exclude": ["src/**/*.test.ts"] } diff --git a/tsconfig.json b/tsconfig.json index be755ed..eb1ac07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", + "customConditions": ["intx-src"], "verbatimModuleSyntax": true, "skipLibCheck": true, "noEmit": true, @@ -12,5 +13,5 @@ "noImplicitOverride": true, "types": ["node", "@types/bun"] }, - "include": ["src/client", "src/index.ts"] + "include": ["src"] } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index fc8520e..ee6cfc9 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -1,3 +1,8 @@ { - "extends": "./tsconfig.json" + "extends": "./tsconfig.json", + "compilerOptions": { + "paths": { + "@intx/agent": ["./src/shims/intx-agent.d.ts"] + } + } } From 3f02cca3389b2a1a294369f7455243e6f9604a51 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Sun, 23 Aug 2026 21:33:16 +0545 Subject: [PATCH 3/5] Add standalone Gmail live tests --- .gitignore | 1 + README.md | 23 ++ package.json | 4 +- tests/live/credential.test.ts | 61 +++++ tests/live/credential.ts | 317 +++++++++++++++++++++++++ tests/live/fixtures.test.ts | 58 +++++ tests/live/fixtures.ts | 49 ++++ tests/live/gmail.mutations.e2e.test.ts | 142 +++++++++++ tests/live/gmail.read.e2e.test.ts | 58 +++++ tests/live/json.ts | 19 ++ tests/live/live-harness.ts | 115 +++++++++ tests/live/oauth.test.ts | 54 +++++ tests/live/oauth.ts | 245 +++++++++++++++++++ tests/live/token-store.ts | 60 +++++ tsconfig.live.json | 7 + 15 files changed, 1212 insertions(+), 1 deletion(-) create mode 100644 tests/live/credential.test.ts create mode 100644 tests/live/credential.ts create mode 100644 tests/live/fixtures.test.ts create mode 100644 tests/live/fixtures.ts create mode 100644 tests/live/gmail.mutations.e2e.test.ts create mode 100644 tests/live/gmail.read.e2e.test.ts create mode 100644 tests/live/json.ts create mode 100644 tests/live/live-harness.ts create mode 100644 tests/live/oauth.test.ts create mode 100644 tests/live/oauth.ts create mode 100644 tests/live/token-store.ts create mode 100644 tsconfig.live.json 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 be35fe1..323bf77 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,29 @@ 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; +`GMAIL_LIVE_DEBUG=1` enables redacted request metadata. + ## License LGPL-2.1-only. See [LICENSE](./LICENSE). diff --git a/package.json b/package.json index 2793b3a..e14df20 100644 --- a/package.json +++ b/package.json @@ -59,9 +59,11 @@ "scripts": { "link:intx": "bun scripts/link-intx.ts", "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 --conditions intx-src src" + "test": "bun test --conditions intx-src src tests/live/credential.test.ts tests/live/fixtures.test.ts tests/live/oauth.test.ts", + "test:live": "bun test --conditions intx-src tests/live" }, "peerDependencies": { "@intx/agent": "*", 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..9adf385 --- /dev/null +++ b/tests/live/credential.ts @@ -0,0 +1,317 @@ +import type { HttpMediatedCredential } from "@intx/types"; + +import type { GmailFetch } from "../../src/client/index.js"; +import { + defaultGmailLiveLogger, + refreshGmailAccessToken, + type GmailAccessToken, + type GmailLiveLogger, + type GmailOAuthConfig, +} from "./oauth.js"; +import { getArray, getString, isRecord } from "./json.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; + debug?: boolean; + showContent?: boolean; + logger?: GmailLiveLogger; +}; + +const CONTENT_PREVIEW_LIMIT = 1_000; + +function preview(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const normalized = value.replace(/\s+/g, " ").trim(); + return normalized.length > CONTENT_PREVIEW_LIMIT + ? `${normalized.slice(0, CONTENT_PREVIEW_LIMIT)}…` + : normalized; +} + +function decodeBase64Url(value: string): string { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + try { + return new TextDecoder().decode( + Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)), + ); + } catch { + return ""; + } +} + +function messageContentPreview(message: Record): Record { + const payload = + isRecord(message.payload) + ? message.payload + : undefined; + const headers = payload === undefined ? [] : (getArray(payload, "headers") ?? []); + const selectedHeaders: Record = {}; + for (const header of headers) { + if (!isRecord(header)) continue; + const name = getString(header, "name"); + const value = getString(header, "value"); + if ( + typeof name === "string" && + typeof value === "string" && + ["subject", "from", "to", "cc", "date"].includes(name.toLowerCase()) + ) { + selectedHeaders[name.toLowerCase()] = value; + } + } + + const bodyParts: Record[] = []; + const visit = (part: unknown): void => { + if (!isRecord(part)) return; + const current = part; + bodyParts.push(current); + const parts = getArray(current, "parts"); + if (parts !== undefined) { + for (const child of parts) visit(child); + } + }; + visit(payload); + const preferredPart = bodyParts.find( + (part) => + getString(part, "mimeType") === "text/plain" && bodyData(part) !== undefined, + ); + const fallbackPart = bodyParts.find( + (part) => + getString(part, "mimeType") === "text/html" && bodyData(part) !== undefined, + ); + const decodedBody = + (preferredPart === undefined ? undefined : bodyData(preferredPart)) ?? + (fallbackPart === undefined ? undefined : bodyData(fallbackPart)); + + return { + ...(typeof message.id === "string" ? { id: message.id } : {}), + ...(typeof message.internalDate === "string" + ? { internalDate: message.internalDate } + : {}), + headers: selectedHeaders, + ...(typeof message.snippet === "string" ? { snippet: preview(message.snippet) } : {}), + ...(decodedBody === undefined + ? {} + : { bodyPreview: preview(decodeBase64Url(decodedBody)) }), + }; +} + +function bodyData(part: Record): string | undefined { + const body = part.body; + return isRecord(body) ? getString(body, "data") : undefined; +} + +function summarizeGmailResponse(path: string, body: unknown): Record { + if (!isRecord(body)) { + return { kind: typeof body }; + } + const value = body; + const threads = getArray(value, "threads"); + if (threads !== undefined) { + return { + kind: "thread-list", + threadCount: threads.length, + threadIds: threads + .slice(0, 10) + .map((thread) => + isRecord(thread) ? getString(thread, "id") : undefined, + ) + .filter((id): id is string => typeof id === "string"), + ...(typeof value.resultSizeEstimate === "number" + ? { resultSizeEstimate: value.resultSizeEstimate } + : {}), + ...(typeof value.nextPageToken === "string" ? { hasNextPage: true } : {}), + }; + } + const messages = getArray(value, "messages"); + if (messages !== undefined) { + return { + kind: "thread", + ...(typeof value.id === "string" ? { threadId: value.id } : {}), + messageCount: messages.length, + messageIds: messages + .slice(0, 10) + .map((message) => + isRecord(message) ? getString(message, "id") : undefined, + ) + .filter((id): id is string => typeof id === "string"), + }; + } + const labels = getArray(value, "labels"); + if (labels !== undefined) { + return { + kind: "label-list", + labelCount: labels.length, + labelIds: labels + .slice(0, 20) + .map((label) => + isRecord(label) ? getString(label, "id") : undefined, + ) + .filter((id): id is string => typeof id === "string"), + }; + } + if (typeof value.id === "string") { + const payload = + isRecord(value.payload) + ? value.payload + : undefined; + const headers = payload === undefined ? [] : getArray(payload, "headers") ?? []; + const headerNames = + headers + .map((header) => + isRecord(header) ? getString(header, "name") : undefined, + ) + .filter((name): name is string => typeof name === "string"); + return { + kind: "message", + messageId: value.id, + ...(typeof value.threadId === "string" ? { threadId: value.threadId } : {}), + ...(Array.isArray(value.labelIds) ? { labelIds: value.labelIds } : {}), + headerNames, + hasPayloadBody: payload?.body !== undefined || Array.isArray(payload?.parts), + }; + } + return { kind: "object", keys: Object.keys(value).slice(0, 20), path }; +} + +function addContentToSummary( + summary: Record, + body: unknown, +): Record { + if (!isRecord(body)) return summary; + const value = body; + + const threads = getArray(value, "threads"); + if (threads !== undefined) { + return { + ...summary, + threadPreviews: threads.slice(0, 10).flatMap((thread) => { + if (!isRecord(thread)) return []; + const item = thread; + return [ + { + ...(typeof item.id === "string" ? { id: item.id } : {}), + ...(typeof item.snippet === "string" + ? { snippet: preview(item.snippet) } + : {}), + }, + ]; + }), + }; + } + + const messages = getArray(value, "messages"); + if (messages !== undefined) { + return { + ...summary, + messagePreviews: messages + .slice(0, 10) + .flatMap((message) => + isRecord(message) + ? [messageContentPreview(message)] + : [], + ), + }; + } + + if (typeof value.id === "string" && value.payload !== undefined) { + return { ...summary, content: messageContentPreview(value) }; + } + + return summary; +} + +export function createStandaloneGmailCredential( + options: StandaloneGmailCredentialOptions, +): HttpMediatedCredential { + const networkFetch = options.fetchImpl ?? globalThis.fetch; + const logger = options.logger ?? defaultGmailLiveLogger; + 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)}`); + + const startedAt = Date.now(); + const response = await networkFetch( + new Request(sourceRequest ?? url, { + ...init, + headers, + redirect: "manual", + }), + ); + if (options.debug && url.origin === GMAIL_API_ORIGIN) { + let summary: Record = { kind: "unreadable" }; + let responseBodyForLogging: unknown; + try { + responseBodyForLogging = await response.clone().json(); + summary = summarizeGmailResponse(url.pathname, responseBodyForLogging); + } catch { + summary = { kind: "non-json" }; + } + logger.info( + `[gmail-live] ${JSON.stringify({ + method: (init?.method ?? sourceRequest?.method ?? "GET").toUpperCase(), + path: url.pathname, + status: response.status, + durationMs: Date.now() - startedAt, + response: options.showContent + ? addContentToSummary(summary, responseBodyForLogging) + : summary, + })}`, + ); + } + return response; + } + + 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..e26d5c2 --- /dev/null +++ b/tests/live/live-harness.ts @@ -0,0 +1,115 @@ +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, + debug: process.env.GMAIL_LIVE_DEBUG === "1" || process.env.GMAIL_LIVE_SHOW_CONTENT === "1", + showContent: process.env.GMAIL_LIVE_SHOW_CONTENT === "1", + }); + 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..2ab5f61 --- /dev/null +++ b/tests/live/oauth.ts @@ -0,0 +1,245 @@ +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 = createDeferred(); + 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); +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} { + let resolve: ((value: T) => void) | undefined; + let reject: ((error: Error) => void) | undefined; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + if (resolve === undefined || reject === undefined) { + throw new Error("could not create OAuth callback promise"); + } + return { promise, resolve, reject }; +} diff --git a/tests/live/token-store.ts b/tests/live/token-store.ts new file mode 100644 index 0000000..eff8321 --- /dev/null +++ b/tests/live/token-store.ts @@ -0,0 +1,60 @@ +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 interface GmailTokenStore { + read(): Promise; + write(token: StoredGmailToken): Promise; +} + +export function createFileTokenStore(path: string): GmailTokenStore { + 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) { + 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.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"] +} From 372e3e7e3a3819107c4aa7d6993b97c7b31101ee Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 24 Aug 2026 12:19:37 +0545 Subject: [PATCH 4/5] Remove local Interchange type workarounds --- README.md | 3 --- bun.lock | 22 ++++++++++++++++++++ package.json | 7 ++++--- scripts/link-intx.ts | 34 ------------------------------- src/shims/intx-agent.d.ts | 43 --------------------------------------- tsconfig.build.json | 3 --- tsconfig.json | 1 - tsconfig.typecheck.json | 7 +------ 8 files changed, 27 insertions(+), 93 deletions(-) delete mode 100644 scripts/link-intx.ts delete mode 100644 src/shims/intx-agent.d.ts diff --git a/README.md b/README.md index 323bf77..ccef2e9 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,8 @@ bun add github:corbitsdev/google-tools ## Working on it -Use a sibling Interchange checkout at `../interchange` to run the tool tests. - ```bash bun install -bun run link:intx bun run typecheck bun test bun run build diff --git a/bun.lock b/bun.lock index f9e5df7..9025fb3 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,8 @@ "arktype": "^2.2.3", }, "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", @@ -27,6 +29,22 @@ "@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=="], + + "@logtape/logtape": ["@logtape/logtape@2.3.2", "", {}, "sha512-SfxHfSdDlTp6tLpYP5uezNitSCTQUB3FZ1MXDbgDBN9t9kvF+XL0rOGTIa/pJcDMy1o9FELaZ25E/Ypmsf5fNQ=="], + "@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=="], @@ -37,6 +55,10 @@ "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@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], diff --git a/package.json b/package.json index e14df20..65ee917 100644 --- a/package.json +++ b/package.json @@ -57,13 +57,12 @@ ], "sideEffects": false, "scripts": { - "link:intx": "bun scripts/link-intx.ts", "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 --conditions intx-src src tests/live/credential.test.ts tests/live/fixtures.test.ts tests/live/oauth.test.ts", - "test:live": "bun test --conditions intx-src tests/live" + "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": { "@intx/agent": "*", @@ -74,6 +73,8 @@ "@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" diff --git a/scripts/link-intx.ts b/scripts/link-intx.ts deleted file mode 100644 index 9330cf5..0000000 --- a/scripts/link-intx.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { existsSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const interchangeRoot = resolve(root, "../interchange"); -const sourceRoot = join(interchangeRoot, "node_modules/@intx"); -const destinationRoot = join(root, "node_modules/@intx"); -const packages: readonly string[] = [ - "agent", - "types", - "inference", - "log", - "mime", - "crypto", -]; - -if (!existsSync(sourceRoot)) { - console.warn(`link-intx: ${sourceRoot} not found — skip`); - process.exit(0); -} - -mkdirSync(destinationRoot, { recursive: true }); -for (const name of packages) { - const source = join(sourceRoot, name); - const destination = join(destinationRoot, name); - if (!existsSync(source)) { - console.warn(`link-intx: missing ${source} — skip`); - continue; - } - rmSync(destination, { recursive: true, force: true }); - symlinkSync(source, destination); - console.log(`link-intx: @intx/${name} -> ${source}`); -} diff --git a/src/shims/intx-agent.d.ts b/src/shims/intx-agent.d.ts deleted file mode 100644 index f86b0cf..0000000 --- a/src/shims/intx-agent.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { - ToolCall, - ToolDefinition, - ToolResult, -} from "@intx/types/runtime"; - -export interface BaseEnv { - [key: string]: unknown; -} - -export interface ToolDeclaration { - readonly name: string; - readonly approval?: "ask"; -} - -export type ToolHandler = ( - call: ToolCall, - signal: AbortSignal, -) => Promise; - -export interface ToolBundle { - readonly definitions: readonly ToolDefinition[]; - run(call: ToolCall, signal: AbortSignal): Promise; - dispose?(): Promise; -} - -export type ToolFactory = ( - env: EnvReq, -) => ToolBundle; - -export type AnnotatedToolFactory = - ToolFactory & { - readonly id: string; - readonly requires: readonly string[]; - readonly definitions: readonly ToolDeclaration[]; - }; - -export function defineTool(opts: { - id: string; - requires?: readonly string[]; - definitions: readonly ToolDeclaration[]; - factory: ToolFactory; -}): AnnotatedToolFactory; diff --git a/tsconfig.build.json b/tsconfig.build.json index 67308b3..7b4187d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -2,9 +2,6 @@ "extends": "./tsconfig.json", "compilerOptions": { "types": ["node"], - "paths": { - "@intx/agent": ["./src/shims/intx-agent.d.ts"] - }, "noEmit": false, "declaration": true, "declarationMap": true, diff --git a/tsconfig.json b/tsconfig.json index eb1ac07..2914d81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,6 @@ "target": "ESNext", "module": "ESNext", "moduleResolution": "bundler", - "customConditions": ["intx-src"], "verbatimModuleSyntax": true, "skipLibCheck": true, "noEmit": true, diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index ee6cfc9..fc8520e 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -1,8 +1,3 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "paths": { - "@intx/agent": ["./src/shims/intx-agent.d.ts"] - } - } + "extends": "./tsconfig.json" } From fe1c8a58fa66dff9c613a8ddc6015d525c792080 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Mon, 24 Aug 2026 12:51:19 +0545 Subject: [PATCH 5/5] Simplify Gmail tools and live test plumbing --- README.md | 3 +- src/sidecar-bundle.ts | 12 +- src/tools/create-tools.ts | 53 ++++----- tests/live/credential.ts | 235 +------------------------------------ tests/live/live-harness.ts | 2 - tests/live/oauth.ts | 19 +-- tests/live/token-store.ts | 9 +- 7 files changed, 30 insertions(+), 303 deletions(-) diff --git a/README.md b/README.md index ccef2e9..b5cdfd2 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,7 @@ 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; -`GMAIL_LIVE_DEBUG=1` enables redacted request metadata. +different token file. Credentials and message contents are not logged. ## License diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts index d4da3b1..773e65d 100644 --- a/src/sidecar-bundle.ts +++ b/src/sidecar-bundle.ts @@ -11,7 +11,6 @@ const MUTATING_TOOL_NAMES = new Set([ "gmail_label_thread", "gmail_unlabel_thread", ]); -const MUTATING_TOOL_APPROVAL: "ask" = "ask"; export interface GmailToolEnv extends BaseEnv { capabilities: RuntimeCapabilities; @@ -23,15 +22,8 @@ export const gmail = defineTool({ definitions: TOOL_DEFINITIONS.map((definition) => ({ name: definition.name, ...(MUTATING_TOOL_NAMES.has(definition.name) - ? { approval: MUTATING_TOOL_APPROVAL } + ? { approval: "ask" as const } : {}), })), - factory: (env) => { - const tools = createGmailTools({ capabilities: env.capabilities }); - return { - definitions: tools.definitions, - run: (call, signal) => tools.run(call, signal), - dispose: () => tools.dispose(), - }; - }, + factory: (env) => createGmailTools({ capabilities: env.capabilities }), }); diff --git a/src/tools/create-tools.ts b/src/tools/create-tools.ts index c29153c..53c317c 100644 --- a/src/tools/create-tools.ts +++ b/src/tools/create-tools.ts @@ -1,4 +1,3 @@ -import type { ToolHandler } from "@intx/agent"; import { ArkErrors, type Type } from "arktype"; import type { ToolCall, @@ -26,7 +25,7 @@ import { type GmailMessageFormat, type GmailThreadView, } from "./models.js"; -import { createRawDraft, type DraftInput, type ReplyContext } from "./drafts.js"; +import { createRawDraft, type ReplyContext } from "./drafts.js"; import { CreateDraftInput, GetMessageInput, @@ -47,7 +46,11 @@ export interface GmailTools extends ToolRunner { dispose(): Promise; } -type ToolHandlerFactory = (client: GmailClient) => ToolHandler; +type GmailToolHandler = ( + client: GmailClient, + call: ToolCall, + signal: AbortSignal, +) => Promise; function parseToolInput( validator: T, @@ -177,13 +180,10 @@ async function listDraftsForQuery( }; } -const HANDLER_FACTORIES: ReadonlyMap = new Map< - string, - ToolHandlerFactory ->([ +const HANDLERS = new Map([ [ "gmail_search_threads", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(SearchThreadsInput, call.arguments); const response = await client.listThreads({ query: input.query, @@ -225,7 +225,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_get_thread", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(GetThreadInput, call.arguments); const thread = await client.getThread(input.threadId, { format: toGmailFormat(input.messageFormat), @@ -240,7 +240,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_get_message", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(GetMessageInput, call.arguments); const message = await client.getMessage(input.messageId, { format: toGmailFormat(input.messageFormat), @@ -255,7 +255,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_list_labels", - (client) => async (call, signal) => { + async (client, call, signal) => { parseToolInput(ListLabelsInput, call.arguments); const response = await client.listLabels(signal); return { @@ -266,18 +266,11 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_create_draft", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(CreateDraftInput, call.arguments); rejectUnsupportedAttachments(input.attachments); - const draft: DraftInput = { - ...(input.to === undefined ? {} : { to: input.to }), - ...(input.cc === undefined ? {} : { cc: input.cc }), - ...(input.bcc === undefined ? {} : { bcc: input.bcc }), - ...(input.subject === undefined ? {} : { subject: input.subject }), - ...(input.body === undefined ? {} : { body: input.body }), - ...(input.htmlBody === undefined ? {} : { htmlBody: input.htmlBody }), - }; - const reply = await replyContext(client, input.replyToMessageId, signal); + 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 }), @@ -291,7 +284,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_list_drafts", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(ListDraftsInput, call.arguments); const format = draftFormat(input.view); const response = @@ -335,7 +328,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_label_message", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(LabelMessageInput, call.arguments); const message = await client.modifyMessage( input.messageId, @@ -350,7 +343,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_unlabel_message", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(UnlabelMessageInput, call.arguments); const message = await client.modifyMessage( input.messageId, @@ -365,7 +358,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_label_thread", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(LabelThreadInput, call.arguments); const thread = await client.modifyThread( input.threadId, @@ -380,7 +373,7 @@ const HANDLER_FACTORIES: ReadonlyMap = new Map< ], [ "gmail_unlabel_thread", - (client) => async (call, signal) => { + async (client, call, signal) => { const input = parseToolInput(UnlabelThreadInput, call.arguments); const thread = await client.modifyThread( input.threadId, @@ -422,8 +415,8 @@ export function createGmailTools(opts: CreateGmailToolsOptions): GmailTools { return { definitions: TOOL_DEFINITIONS, async run(call: ToolCall, signal: AbortSignal): Promise { - const factory = HANDLER_FACTORIES.get(call.name); - if (factory === undefined) { + const handler = HANDLERS.get(call.name); + if (handler === undefined) { return { callId: call.id, content: { error: `Unknown tool: "${call.name}"` }, @@ -432,7 +425,7 @@ export function createGmailTools(opts: CreateGmailToolsOptions): GmailTools { } try { - return await factory(await getClient())(call, signal); + return await handler(await getClient(), call, signal); } catch (error) { return { callId: call.id, @@ -456,7 +449,7 @@ export function createGmailTools(opts: CreateGmailToolsOptions): GmailTools { function assertCatalogMatchesHandlers(): void { const definitions = TOOL_DEFINITIONS.map((definition) => definition.name).sort(); - const handlers = [...HANDLER_FACTORIES.keys()].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/tests/live/credential.ts b/tests/live/credential.ts index 9adf385..761164e 100644 --- a/tests/live/credential.ts +++ b/tests/live/credential.ts @@ -2,13 +2,10 @@ import type { HttpMediatedCredential } from "@intx/types"; import type { GmailFetch } from "../../src/client/index.js"; import { - defaultGmailLiveLogger, refreshGmailAccessToken, type GmailAccessToken, - type GmailLiveLogger, type GmailOAuthConfig, } from "./oauth.js"; -import { getArray, getString, isRecord } from "./json.js"; import type { StoredGmailToken } from "./token-store.js"; export const GMAIL_API_ORIGIN = "https://gmail.googleapis.com"; @@ -17,219 +14,12 @@ export type StandaloneGmailCredentialOptions = { config: GmailOAuthConfig; token: StoredGmailToken; fetchImpl?: GmailFetch; - debug?: boolean; - showContent?: boolean; - logger?: GmailLiveLogger; }; -const CONTENT_PREVIEW_LIMIT = 1_000; - -function preview(value: string | undefined): string | undefined { - if (value === undefined) return undefined; - const normalized = value.replace(/\s+/g, " ").trim(); - return normalized.length > CONTENT_PREVIEW_LIMIT - ? `${normalized.slice(0, CONTENT_PREVIEW_LIMIT)}…` - : normalized; -} - -function decodeBase64Url(value: string): string { - const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); - const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); - try { - return new TextDecoder().decode( - Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)), - ); - } catch { - return ""; - } -} - -function messageContentPreview(message: Record): Record { - const payload = - isRecord(message.payload) - ? message.payload - : undefined; - const headers = payload === undefined ? [] : (getArray(payload, "headers") ?? []); - const selectedHeaders: Record = {}; - for (const header of headers) { - if (!isRecord(header)) continue; - const name = getString(header, "name"); - const value = getString(header, "value"); - if ( - typeof name === "string" && - typeof value === "string" && - ["subject", "from", "to", "cc", "date"].includes(name.toLowerCase()) - ) { - selectedHeaders[name.toLowerCase()] = value; - } - } - - const bodyParts: Record[] = []; - const visit = (part: unknown): void => { - if (!isRecord(part)) return; - const current = part; - bodyParts.push(current); - const parts = getArray(current, "parts"); - if (parts !== undefined) { - for (const child of parts) visit(child); - } - }; - visit(payload); - const preferredPart = bodyParts.find( - (part) => - getString(part, "mimeType") === "text/plain" && bodyData(part) !== undefined, - ); - const fallbackPart = bodyParts.find( - (part) => - getString(part, "mimeType") === "text/html" && bodyData(part) !== undefined, - ); - const decodedBody = - (preferredPart === undefined ? undefined : bodyData(preferredPart)) ?? - (fallbackPart === undefined ? undefined : bodyData(fallbackPart)); - - return { - ...(typeof message.id === "string" ? { id: message.id } : {}), - ...(typeof message.internalDate === "string" - ? { internalDate: message.internalDate } - : {}), - headers: selectedHeaders, - ...(typeof message.snippet === "string" ? { snippet: preview(message.snippet) } : {}), - ...(decodedBody === undefined - ? {} - : { bodyPreview: preview(decodeBase64Url(decodedBody)) }), - }; -} - -function bodyData(part: Record): string | undefined { - const body = part.body; - return isRecord(body) ? getString(body, "data") : undefined; -} - -function summarizeGmailResponse(path: string, body: unknown): Record { - if (!isRecord(body)) { - return { kind: typeof body }; - } - const value = body; - const threads = getArray(value, "threads"); - if (threads !== undefined) { - return { - kind: "thread-list", - threadCount: threads.length, - threadIds: threads - .slice(0, 10) - .map((thread) => - isRecord(thread) ? getString(thread, "id") : undefined, - ) - .filter((id): id is string => typeof id === "string"), - ...(typeof value.resultSizeEstimate === "number" - ? { resultSizeEstimate: value.resultSizeEstimate } - : {}), - ...(typeof value.nextPageToken === "string" ? { hasNextPage: true } : {}), - }; - } - const messages = getArray(value, "messages"); - if (messages !== undefined) { - return { - kind: "thread", - ...(typeof value.id === "string" ? { threadId: value.id } : {}), - messageCount: messages.length, - messageIds: messages - .slice(0, 10) - .map((message) => - isRecord(message) ? getString(message, "id") : undefined, - ) - .filter((id): id is string => typeof id === "string"), - }; - } - const labels = getArray(value, "labels"); - if (labels !== undefined) { - return { - kind: "label-list", - labelCount: labels.length, - labelIds: labels - .slice(0, 20) - .map((label) => - isRecord(label) ? getString(label, "id") : undefined, - ) - .filter((id): id is string => typeof id === "string"), - }; - } - if (typeof value.id === "string") { - const payload = - isRecord(value.payload) - ? value.payload - : undefined; - const headers = payload === undefined ? [] : getArray(payload, "headers") ?? []; - const headerNames = - headers - .map((header) => - isRecord(header) ? getString(header, "name") : undefined, - ) - .filter((name): name is string => typeof name === "string"); - return { - kind: "message", - messageId: value.id, - ...(typeof value.threadId === "string" ? { threadId: value.threadId } : {}), - ...(Array.isArray(value.labelIds) ? { labelIds: value.labelIds } : {}), - headerNames, - hasPayloadBody: payload?.body !== undefined || Array.isArray(payload?.parts), - }; - } - return { kind: "object", keys: Object.keys(value).slice(0, 20), path }; -} - -function addContentToSummary( - summary: Record, - body: unknown, -): Record { - if (!isRecord(body)) return summary; - const value = body; - - const threads = getArray(value, "threads"); - if (threads !== undefined) { - return { - ...summary, - threadPreviews: threads.slice(0, 10).flatMap((thread) => { - if (!isRecord(thread)) return []; - const item = thread; - return [ - { - ...(typeof item.id === "string" ? { id: item.id } : {}), - ...(typeof item.snippet === "string" - ? { snippet: preview(item.snippet) } - : {}), - }, - ]; - }), - }; - } - - const messages = getArray(value, "messages"); - if (messages !== undefined) { - return { - ...summary, - messagePreviews: messages - .slice(0, 10) - .flatMap((message) => - isRecord(message) - ? [messageContentPreview(message)] - : [], - ), - }; - } - - if (typeof value.id === "string" && value.payload !== undefined) { - return { ...summary, content: messageContentPreview(value) }; - } - - return summary; -} - export function createStandaloneGmailCredential( options: StandaloneGmailCredentialOptions, ): HttpMediatedCredential { const networkFetch = options.fetchImpl ?? globalThis.fetch; - const logger = options.logger ?? defaultGmailLiveLogger; let accessToken: GmailAccessToken | undefined; let refreshPromise: Promise | undefined; @@ -268,36 +58,13 @@ export function createStandaloneGmailCredential( new Headers(init?.headers).forEach((value, key) => headers.set(key, value)); headers.set("Authorization", `Bearer ${await getAccessToken(forceRefresh)}`); - const startedAt = Date.now(); - const response = await networkFetch( + return networkFetch( new Request(sourceRequest ?? url, { ...init, headers, redirect: "manual", }), ); - if (options.debug && url.origin === GMAIL_API_ORIGIN) { - let summary: Record = { kind: "unreadable" }; - let responseBodyForLogging: unknown; - try { - responseBodyForLogging = await response.clone().json(); - summary = summarizeGmailResponse(url.pathname, responseBodyForLogging); - } catch { - summary = { kind: "non-json" }; - } - logger.info( - `[gmail-live] ${JSON.stringify({ - method: (init?.method ?? sourceRequest?.method ?? "GET").toUpperCase(), - path: url.pathname, - status: response.status, - durationMs: Date.now() - startedAt, - response: options.showContent - ? addContentToSummary(summary, responseBodyForLogging) - : summary, - })}`, - ); - } - return response; } return { diff --git a/tests/live/live-harness.ts b/tests/live/live-harness.ts index e26d5c2..ae7852a 100644 --- a/tests/live/live-harness.ts +++ b/tests/live/live-harness.ts @@ -30,8 +30,6 @@ export async function createLiveHarness(): Promise { const credential = createStandaloneGmailCredential({ config, token, - debug: process.env.GMAIL_LIVE_DEBUG === "1" || process.env.GMAIL_LIVE_SHOW_CONTENT === "1", - showContent: process.env.GMAIL_LIVE_SHOW_CONTENT === "1", }); const tools = createGmailTools({ capabilities: createRuntimeCapabilities({ diff --git a/tests/live/oauth.ts b/tests/live/oauth.ts index 2ab5f61..e2a39a6 100644 --- a/tests/live/oauth.ts +++ b/tests/live/oauth.ts @@ -157,7 +157,7 @@ export async function authorizeGmail( ): Promise { const state = randomUUID(); let stopServer = (): void => undefined; - const code = createDeferred(); + const code = Promise.withResolvers(); const server = Bun.serve({ hostname: "127.0.0.1", port: config.port, @@ -226,20 +226,3 @@ export async function authorizeGmail( return exchangeAuthorizationCode(config, authorizationCode, redirectUri, fetchImpl); } - -function createDeferred(): { - promise: Promise; - resolve: (value: T) => void; - reject: (error: Error) => void; -} { - let resolve: ((value: T) => void) | undefined; - let reject: ((error: Error) => void) | undefined; - const promise = new Promise((resolvePromise, rejectPromise) => { - resolve = resolvePromise; - reject = rejectPromise; - }); - if (resolve === undefined || reject === undefined) { - throw new Error("could not create OAuth callback promise"); - } - return { promise, resolve, reject }; -} diff --git a/tests/live/token-store.ts b/tests/live/token-store.ts index eff8321..c4dd038 100644 --- a/tests/live/token-store.ts +++ b/tests/live/token-store.ts @@ -8,12 +8,7 @@ export type StoredGmailToken = { grantedScopes?: string[]; }; -export interface GmailTokenStore { - read(): Promise; - write(token: StoredGmailToken): Promise; -} - -export function createFileTokenStore(path: string): GmailTokenStore { +export function createFileTokenStore(path: string) { return { async read() { let raw: string; @@ -29,7 +24,7 @@ export function createFileTokenStore(path: string): GmailTokenStore { throw new Error(`invalid Gmail token file: ${path}`, { cause }); } }, - async write(token) { + async write(token: StoredGmailToken) { await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await writeFile(path, `${JSON.stringify(token, null, 2)}\n`, { encoding: "utf8",