diff --git a/src/tools/create-tools.test.ts b/src/tools/create-tools.test.ts index f6ae99a..67a75c3 100644 --- a/src/tools/create-tools.test.ts +++ b/src/tools/create-tools.test.ts @@ -262,6 +262,75 @@ describe("createGmailTools", () => { await tools.dispose(); }); + test("skips a thread that 404s instead of failing the page", async () => { + const tools = createGmailTools({ + capabilities: testCapabilities(async (input) => { + const url = new URL(String(input)); + if (url.pathname === "/gmail/v1/users/me/threads") { + return new Response( + JSON.stringify({ threads: [{ id: "thread-1" }, { id: "gone" }] }), + ); + } + if (url.pathname.endsWith("/gone")) { + return new Response("{}", { status: 404, statusText: "Not Found" }); + } + return new Response(JSON.stringify({ id: "thread-1", messages: [] })); + }), + }); + const result = await tools.run( + { id: "call-gone", name: "gmail_search_threads", arguments: {} }, + new AbortController().signal, + ); + expect(result.isError).toBeUndefined(); + expect(result.content).toEqual({ + data: { threads: [{ id: "thread-1", messages: [] }] }, + }); + await tools.dispose(); + }); + + test("skips malformed base64 parts and decodes the declared charset", async () => { + const tools = createGmailTools({ + capabilities: testCapabilities( + async () => + new Response( + JSON.stringify({ + id: "message-1", + payload: { + mimeType: "multipart/alternative", + parts: [ + { + mimeType: "text/plain", + headers: [ + { + name: "Content-Type", + value: 'text/plain; charset="ISO-8859-1"', + }, + ], + body: { + data: Buffer.from("café", "latin1").toString("base64url"), + }, + }, + { mimeType: "text/html", body: { data: "!!!not-base64!!!" } }, + ], + }, + }), + ), + ), + }); + const result = await tools.run( + { + id: "call-charset", + name: "gmail_get_message", + arguments: { messageId: "message-1" }, + }, + new AbortController().signal, + ); + expect(result.content).toEqual({ + data: { id: "message-1", labelIds: [], plaintextBody: "café" }, + }); + await tools.dispose(); + }); + test("returns complete decoded text, HTML, and attachment metadata", async () => { const plaintextBody = "a".repeat(70_000); const fetchImpl = createFetchImpl(async (input) => { diff --git a/src/tools/create-tools.ts b/src/tools/create-tools.ts index 108c615..11cad44 100644 --- a/src/tools/create-tools.ts +++ b/src/tools/create-tools.ts @@ -10,6 +10,7 @@ import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; import { createGmailClient, + GmailApiError, type GmailClient, type GmailDraft, type GmailFetch, @@ -223,10 +224,13 @@ async function lookupThreads( view: GmailThreadView, signal: AbortSignal, ) { - const threads = Array.from<{ - id: string; - messages: ReturnType[]; - }>({ + const threads = Array.from< + | { + id: string; + messages: ReturnType[]; + } + | undefined + >({ length: threadIds.length, }); let nextIndex = 0; @@ -252,6 +256,8 @@ async function lookupThreads( ), }; } catch (error) { + // A thread deleted between list and lookup drops out of the page. + if (error instanceof GmailApiError && error.status === 404) continue; failed = true; throw error; } @@ -263,7 +269,7 @@ async function lookupThreads( worker, ), ); - return threads; + return threads.filter((thread) => thread !== undefined); } const HANDLERS = new Map([ diff --git a/src/tools/models.ts b/src/tools/models.ts index 4f6f8ad..16b9f3b 100644 --- a/src/tools/models.ts +++ b/src/tools/models.ts @@ -70,12 +70,27 @@ function headersByName( return result; } -function decodeBase64Url(value: string): string { +function decodeBase64Url(value: string): Uint8Array | undefined { 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)), - ); + try { + return Uint8Array.from(atob(padded), (character) => + character.charCodeAt(0), + ); + } catch { + return undefined; + } +} + +function textDecoder(part: GmailMessagePart): TextDecoder { + const contentType = headersByName(part.headers).get("content-type") ?? ""; + const charset = /charset\s*=\s*"?([^";\s]+)/i.exec(contentType)?.[1]; + try { + // bun-types narrows the label to three encodings; runtimes accept any WHATWG label. + return new TextDecoder(charset as Bun.Encoding | undefined); + } catch { + return new TextDecoder(); + } } function collectParts(part: GmailMessagePart | undefined): GmailMessagePart[] { @@ -96,7 +111,8 @@ function bodyForMimeType( ) { return []; } - return [decodeBase64Url(part.body.data)]; + const bytes = decodeBase64Url(part.body.data); + return bytes === undefined ? [] : [textDecoder(part).decode(bytes)]; }); return values.length === 0 ? undefined : values.join("\n\n"); }