Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions agent/lib/google-workspace/gmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,23 @@ export async function readGmailThread(ctx: ToolContext, threadId: string) {
);
return {
id: thread.id ?? threadId,
messages: (thread.messages ?? []).slice(-20).map((message) =>
Object.assign({}, minimizeMessage(message), {
attachments: collectAttachments(message.payload),
body: redactGoogleText(plainText(message.payload)),
})
messages: await Promise.all(
(thread.messages ?? []).slice(-20).map(async (message) =>
Object.assign({}, minimizeMessage(message), {
attachments: collectAttachments(message.payload),
body: redactGoogleText(
await plainText(message.payload, async (attachmentId) => {
if (!message.id)
throw new Error("The Gmail message has no ID.");
const { data } = await client.users.messages.attachments.get(
{ id: attachmentId, messageId: message.id, userId: "me" },
{ signal: ctx.abortSignal }
);
return data.data ?? "";
})
),
})
)
),
};
});
Expand Down Expand Up @@ -174,20 +186,29 @@ function header(part: GmailPart | undefined, name: string) {
);
}

function plainText(part: GmailPart | undefined): string {
if (!part) return "";
if (part.mimeType === "text/plain" && part.body?.data) {
return decodeBase64Url(part.body.data);
async function plainText(
part: GmailPart | undefined,
readAttachment: (attachmentId: string) => Promise<string>
): Promise<string> {
if (!part || part.filename) return "";
if (part.mimeType === "text/plain" || part.mimeType === "text/html") {
let data = part.body?.data;
if (!data && part.body?.attachmentId) {
data = await readAttachment(part.body.attachmentId);
}
if (data) {
const text = decodeBase64Url(data);
return part.mimeType === "text/plain"
? text
: text.replace(/<[^>]+>/gu, " ").replace(/\s+/gu, " ");
}
}
/* oxlint-disable eslint/no-await-in-loop -- Preserve MIME body order and stop after the first readable body. */
for (const child of part.parts ?? []) {
const text = plainText(child);
const text = await plainText(child, readAttachment);
if (text) return text;
}
if (part.mimeType === "text/html" && part.body?.data) {
return decodeBase64Url(part.body.data)
.replace(/<[^>]+>/gu, " ")
.replace(/\s+/gu, " ");
}
/* oxlint-enable eslint/no-await-in-loop */
return "";
}

Expand Down
241 changes: 241 additions & 0 deletions agent/lib/google-workspace/tests/gmail-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
import { createServer } from "node:http";
import * as GmailApi from "@googleapis/gmail";
import type { ToolContext } from "eve/tools";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { z } from "zod";
import { gmailReadThread } from "@agent/tools/gmail";
import { toolContextFor } from "@tests/helpers/tool-context";

let thread: GmailApi.gmail_v1.Schema$Thread = {};
const bodies = new Map<string, GmailApi.gmail_v1.Schema$MessagePartBody>();
const requests: { authorization: string | undefined; path: string }[] = [];
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", "http://localhost");
requests.push({
authorization: request.headers.authorization,
path: url.pathname,
});
response.setHeader("content-type", "application/json");
if (url.pathname === "/gmail/v1/users/me/threads/thread-fixture") {
response.end(JSON.stringify(thread));
return;
}
const body = bodies.get(url.pathname);
if (body) {
response.end(JSON.stringify(body));
return;
}
response.statusCode = 404;
response.end(JSON.stringify({ error: { message: "Unknown fixture path" } }));
});

beforeAll(async () => {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = z.object({ port: z.number() }).parse(server.address());
const rootUrl = `http://127.0.0.1:${String(address.port)}`;
// Keep the generated Google client, OAuth header and transport; only redirect the external SDK boundary to the local fixture.
vi.spyOn(GmailApi, "gmail").mockImplementation(
(options) => new GmailApi.gmail_v1.Gmail({ ...options, rootUrl })
);
});

beforeEach(() => {
bodies.clear();
requests.length = 0;
thread = { id: "thread-fixture", messages: [] };
});

afterAll(async () => {
vi.restoreAllMocks();
await new Promise<void>((resolve, reject) =>
server.close((error) => {
if (error) reject(error);
else resolve();
})
);
});

describe("Gmail thread text bodies", () => {
it.each([undefined, ""])(
"reads an external text body with data=%s through the generated attachments API",
async (data) => {
thread.messages = [
{
id: "message-fixture",
payload: {
body: { attachmentId: "body-fixture", data, size: 45 },
mimeType: "text/plain",
},
},
];
bodies.set(
"/gmail/v1/users/me/messages/message-fixture/attachments/body-fixture",
{ data: encode("Meet at noon. Your verification code is 123456.") }
);

const result = await gmailReadThread.execute(
{ threadId: "thread-fixture" },
context()
);
expect(result).toMatchObject({
thread: {
messages: [
{
body: "Meet at noon. Your verification code is [six-digit code redacted].",
},
],
},
});
expect(requests.map((request) => request.path)).toEqual([
"/gmail/v1/users/me/threads/thread-fixture",
"/gmail/v1/users/me/messages/message-fixture/attachments/body-fixture",
]);
expect(
requests.every(
(request) => request.authorization === "Bearer fixture-token"
)
).toBe(true);
}
);

it("keeps inline and empty text bodies without additional requests", async () => {
thread.messages = [
{
id: "inline",
payload: {
mimeType: "text/plain",
body: { data: encode("Inline text") },
},
},
{ id: "empty", payload: { mimeType: "text/plain", body: { size: 0 } } },
];
const result = await gmailReadThread.execute(
{ threadId: "thread-fixture" },
context()
);
expect(result).toMatchObject({
thread: { messages: [{ body: "Inline text" }, { body: "" }] },
});
expect(requests).toHaveLength(1);
});

it("reads external HTML within a multipart body without downloading named attachments", async () => {
thread.messages = [
{
id: "multipart",
payload: {
mimeType: "multipart/mixed",
parts: [
{
mimeType: "text/html",
body: { attachmentId: "html-body", size: 19 },
},
{
filename: "invoice.pdf",
mimeType: "application/pdf",
body: { attachmentId: "invoice", size: 200 },
},
],
},
},
];
bodies.set("/gmail/v1/users/me/messages/multipart/attachments/html-body", {
data: encode("<p>Receipt ready</p>"),
});
const result = await gmailReadThread.execute(
{ threadId: "thread-fixture" },
context()
);
expect(result).toMatchObject({
thread: {
messages: [
{
body: " Receipt ready ",
attachments: [
{ attachmentId: "invoice", filename: "invoice.pdf", size: 200 },
],
},
],
},
});
expect(requests.map((request) => request.path)).toEqual([
"/gmail/v1/users/me/threads/thread-fixture",
"/gmail/v1/users/me/messages/multipart/attachments/html-body",
]);
});

it("preserves nested inline bodies and excludes named text attachments", async () => {
thread.messages = [
{
id: "nested",
payload: {
mimeType: "multipart/mixed",
parts: [
{
filename: "notes.txt",
mimeType: "text/plain",
body: { attachmentId: "notes" },
},
{
mimeType: "multipart/alternative",
parts: [
{
mimeType: "text/plain",
body: { data: encode("Nested body") },
},
{
mimeType: "text/html",
body: { data: encode("<p>Alternative body</p>") },
},
],
},
],
},
},
];
const result = await gmailReadThread.execute(
{ threadId: "thread-fixture" },
context()
);
expect(result).toMatchObject({
thread: { messages: [{ body: "Nested body" }] },
});
expect(requests).toHaveLength(1);
});

it("surfaces an attachment retrieval failure instead of returning an empty body", async () => {
thread.messages = [
{
id: "missing-body",
payload: {
mimeType: "text/plain",
body: { attachmentId: "unavailable" },
},
},
];
await expect(
gmailReadThread.execute({ threadId: "thread-fixture" }, context())
).rejects.toThrow("Unknown fixture path");
});
});

function encode(text: string) {
return Buffer.from(text, "utf8").toString("base64url");
}

function context() {
return {
...toolContextFor({ callId: "read-fixture", sessionId: "gmail-fixture" }),
getToken: vi
.fn<ToolContext["getToken"]>()
.mockResolvedValue({ token: "fixture-token" }),
} satisfies ToolContext;
}