diff --git a/templates/mail/actions/get-email.ts b/templates/mail/actions/get-email.ts index 2765a85e5f..91d0b4f376 100644 --- a/templates/mail/actions/get-email.ts +++ b/templates/mail/actions/get-email.ts @@ -7,15 +7,22 @@ import { gmailGetMessage } from "../server/lib/google-api.js"; import { isConnected, gmailToEmailMessage } from "../server/lib/google-auth.js"; import { getAccessTokens, fetchLabelMap } from "./helpers.js"; +const accountCoordinate = z.union([z.string().email(), z.literal("local")]); + export default defineAction({ description: - "Get a single email by ID, including its full body and metadata.", + "Read one exact email, including its full body and metadata, without changing UNREAD or any other mailbox label.", schema: z.object({ - id: z.string().optional().describe("Email message ID"), + accountEmail: accountCoordinate.describe( + 'Connected account email, or "local" for the synthetic mailbox', + ), + id: z.string().min(1).describe("Provider-scoped email message ID"), }), http: { method: "GET" }, - run: async (args) => { - if (!args.id) throw new Error("--id is required"); + readOnly: true, + publicAgent: { expose: true, readOnly: true, requiresAuth: true }, + run: async (args, ctx) => { + const requestedAccount = args.accountEmail.toLowerCase(); const ownerEmail = getRequestUserEmail(); if (!ownerEmail) throw new Error("no authenticated user"); @@ -23,25 +30,64 @@ export default defineAction({ const data = await getUserSetting(ownerEmail, "local-emails"); const emails = data && Array.isArray((data as any).emails) ? (data as any).emails : []; - const found = emails.find((e: any) => e.id === args.id); + const localAccounts = new Set( + emails.map( + (email: any) => email.accountEmail?.toLowerCase() ?? "local", + ), + ); + if (!localAccounts.has(requestedAccount)) { + throw new Error("Requested local account is not connected."); + } + const found = emails.find( + (e: any) => + e.id === args.id && + (e.accountEmail?.toLowerCase() ?? "local") === requestedAccount, + ); if (!found) throw new Error("Email not found."); - return JSON.stringify(found, null, 2); + const email = { ...found, accountEmail: args.accountEmail }; + return JSON.stringify( + ctx?.caller === "mcp" + ? { + accountEmail: args.accountEmail, + email, + readOnlyGuarantee: { + mailboxLabels: "preserved", + gmailModifyOperations: 0, + }, + } + : email, + null, + 2, + ); } const accounts = await getAccessTokens(); - if (accounts.length === 0) throw new Error("No Google account connected."); + const account = accounts.find( + ({ email }) => email.toLowerCase() === requestedAccount, + ); + if (!account) throw new Error("Requested Google account is not connected."); - for (const { email, accessToken } of accounts) { - try { - const labelMap = await fetchLabelMap(accessToken); - const msg = await gmailGetMessage(accessToken, args.id, "full"); - const parsed = gmailToEmailMessage(msg, email, labelMap); - return JSON.stringify(parsed, null, 2); - } catch (err: any) { - if (err?.message?.includes("404")) continue; - throw new Error(err?.message ?? "Gmail API error"); - } + try { + const labelMap = await fetchLabelMap(account.accessToken); + const msg = await gmailGetMessage(account.accessToken, args.id, "full"); + const email = gmailToEmailMessage(msg, account.email, labelMap); + return JSON.stringify( + ctx?.caller === "mcp" + ? { + accountEmail: account.email, + email, + readOnlyGuarantee: { + mailboxLabels: "preserved", + gmailModifyOperations: 0, + }, + } + : email, + null, + 2, + ); + } catch (err: any) { + if (err?.message?.includes("404")) throw new Error("Email not found."); + throw new Error(err?.message ?? "Gmail API error"); } - throw new Error("Email not found in any connected account."); }, }); diff --git a/templates/mail/actions/get-mail-content.spec.ts b/templates/mail/actions/get-mail-content.spec.ts new file mode 100644 index 0000000000..7070a84d95 --- /dev/null +++ b/templates/mail/actions/get-mail-content.spec.ts @@ -0,0 +1,241 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getRequestUserEmail: vi.fn(), + getUserSetting: vi.fn(), + isConnected: vi.fn(), + gmailToEmailMessage: vi.fn(), + getAccessTokens: vi.fn(), + fetchLabelMap: vi.fn(), + gmailGetMessage: vi.fn(), + gmailGetThread: vi.fn(), + gmailModifyMessage: vi.fn(), + gmailModifyThread: vi.fn(), +})); + +vi.mock("@agent-native/core/server", () => ({ + buildDeepLink: vi.fn(() => "https://mail.example.test/thread"), + getRequestUserEmail: mocks.getRequestUserEmail, +})); + +vi.mock("@agent-native/core/settings", () => ({ + getUserSetting: mocks.getUserSetting, +})); + +vi.mock("../server/lib/google-auth.js", () => ({ + isConnected: mocks.isConnected, + gmailToEmailMessage: mocks.gmailToEmailMessage, +})); + +vi.mock("../server/lib/google-api.js", () => ({ + gmailGetMessage: mocks.gmailGetMessage, + gmailGetThread: mocks.gmailGetThread, + gmailModifyMessage: mocks.gmailModifyMessage, + gmailModifyThread: mocks.gmailModifyThread, +})); + +vi.mock("./helpers.js", () => ({ + getAccessTokens: mocks.getAccessTokens, + fetchLabelMap: mocks.fetchLabelMap, +})); + +import getEmail from "./get-email"; +import getThread from "./get-thread"; + +const OWNER = "owner@example.com"; +const OTHER = "other@example.com"; + +function rawMessage(id: string, threadId: string) { + return { + id, + threadId, + labelIds: ["INBOX", "UNREAD", "IMPORTANT"], + payload: { headers: [] }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getRequestUserEmail.mockReturnValue(OWNER); + mocks.isConnected.mockResolvedValue(true); + mocks.getAccessTokens.mockResolvedValue([ + { email: OWNER, accessToken: "owner-token" }, + { email: OTHER, accessToken: "other-token" }, + ]); + mocks.fetchLabelMap.mockResolvedValue(new Map()); + mocks.gmailToEmailMessage.mockImplementation((message, accountEmail) => ({ + id: message.id, + threadId: message.threadId, + accountEmail, + labelIds: [...message.labelIds], + })); +}); + +describe("exact Mail body reads", () => { + it("requires an account-scoped coordinate in both public schemas", () => { + expect(getEmail.schema.safeParse({ id: "message-1" }).success).toBe(false); + expect(getThread.schema.safeParse({ id: "thread-1" }).success).toBe(false); + expect( + getEmail.schema.safeParse({ accountEmail: OWNER, id: "message-1" }) + .success, + ).toBe(true); + expect( + getThread.schema.safeParse({ accountEmail: OWNER, id: "thread-1" }) + .success, + ).toBe(true); + expect( + getEmail.schema.safeParse({ accountEmail: "local", id: "message-1" }) + .success, + ).toBe(true); + expect( + getThread.schema.safeParse({ accountEmail: "local", id: "thread-1" }) + .success, + ).toBe(true); + }); + + it("reads one message from only the requested account and preserves all labels", async () => { + const message = rawMessage("message-1", "thread-1"); + const labelsBefore = [...message.labelIds]; + mocks.gmailGetMessage.mockResolvedValue(message); + + const result = JSON.parse( + await getEmail.run( + { accountEmail: OWNER, id: message.id }, + { caller: "mcp", userEmail: OWNER }, + ), + ); + + expect(mocks.fetchLabelMap).toHaveBeenCalledOnce(); + expect(mocks.fetchLabelMap).toHaveBeenCalledWith("owner-token"); + expect(mocks.gmailGetMessage).toHaveBeenCalledWith( + "owner-token", + message.id, + "full", + ); + expect(mocks.gmailGetMessage).toHaveBeenCalledTimes(1); + expect(message.labelIds).toEqual(labelsBefore); + expect(result).toMatchObject({ + accountEmail: OWNER, + email: { id: message.id, accountEmail: OWNER }, + readOnlyGuarantee: { + mailboxLabels: "preserved", + gmailModifyOperations: 0, + }, + }); + expect(mocks.gmailModifyMessage).not.toHaveBeenCalled(); + expect(mocks.gmailModifyThread).not.toHaveBeenCalled(); + }); + + it("reads one thread from only the requested account and preserves every message label", async () => { + const messages = [ + rawMessage("message-1", "thread-1"), + rawMessage("message-2", "thread-1"), + ]; + const labelsBefore = messages.map((message) => [...message.labelIds]); + mocks.gmailGetThread.mockResolvedValue({ messages }); + + const result = JSON.parse( + await getThread.run( + { accountEmail: OTHER, id: "thread-1" }, + { caller: "mcp", userEmail: OWNER }, + ), + ); + + expect(mocks.fetchLabelMap).toHaveBeenCalledOnce(); + expect(mocks.fetchLabelMap).toHaveBeenCalledWith("other-token"); + expect(mocks.gmailGetThread).toHaveBeenCalledWith( + "other-token", + "thread-1", + "full", + ); + expect(mocks.gmailGetThread).toHaveBeenCalledTimes(1); + expect(messages.map((message) => message.labelIds)).toEqual(labelsBefore); + expect(result).toMatchObject({ + accountEmail: OTHER, + messages: [ + { id: "message-1", accountEmail: OTHER }, + { id: "message-2", accountEmail: OTHER }, + ], + readOnlyGuarantee: { + mailboxLabels: "preserved", + gmailModifyOperations: 0, + }, + }); + expect(mocks.gmailModifyMessage).not.toHaveBeenCalled(); + expect(mocks.gmailModifyThread).not.toHaveBeenCalled(); + }); + + it("fails a mismatched account without probing any mailbox", async () => { + await expect( + getEmail.run({ + accountEmail: "missing@example.com", + id: "message-1", + }), + ).rejects.toThrow("Requested Google account is not connected."); + await expect( + getThread.run({ + accountEmail: "missing@example.com", + id: "thread-1", + }), + ).rejects.toThrow("Requested Google account is not connected."); + + expect(mocks.fetchLabelMap).not.toHaveBeenCalled(); + expect(mocks.gmailGetMessage).not.toHaveBeenCalled(); + expect(mocks.gmailGetThread).not.toHaveBeenCalled(); + expect(mocks.gmailModifyMessage).not.toHaveBeenCalled(); + expect(mocks.gmailModifyThread).not.toHaveBeenCalled(); + }); + + it("uses the inventory-compatible local coordinate for unscoped synthetic mail", async () => { + mocks.isConnected.mockResolvedValue(false); + mocks.getUserSetting.mockResolvedValue({ + emails: [rawMessage("message-1", "thread-1")], + }); + + const email = JSON.parse( + await getEmail.run( + { accountEmail: "local", id: "message-1" }, + { caller: "mcp", userEmail: OWNER }, + ), + ); + const thread = JSON.parse( + await getThread.run( + { accountEmail: "local", id: "thread-1" }, + { caller: "mcp", userEmail: OWNER }, + ), + ); + + expect(email).toMatchObject({ + accountEmail: "local", + email: { id: "message-1", accountEmail: "local" }, + }); + expect(thread).toMatchObject({ + accountEmail: "local", + messages: [{ id: "message-1" }], + }); + }); + + it("does not let unscoped synthetic mail match a scoped account", async () => { + mocks.isConnected.mockResolvedValue(false); + mocks.getUserSetting.mockResolvedValue({ + emails: [ + rawMessage("local-message", "local-thread"), + { + ...rawMessage("other-message", "other-thread"), + accountEmail: OTHER, + }, + ], + }); + + await expect( + getEmail.run({ accountEmail: OTHER, id: "local-message" }), + ).rejects.toThrow("Email not found."); + await expect( + getThread.run({ accountEmail: OTHER, id: "local-thread" }), + ).rejects.toThrow("Thread not found."); + + expect(mocks.getAccessTokens).not.toHaveBeenCalled(); + expect(mocks.gmailGetMessage).not.toHaveBeenCalled(); + expect(mocks.gmailGetThread).not.toHaveBeenCalled(); + }); +}); diff --git a/templates/mail/actions/get-thread.ts b/templates/mail/actions/get-thread.ts index 7b6cb85faf..2ea76a7fc6 100644 --- a/templates/mail/actions/get-thread.ts +++ b/templates/mail/actions/get-thread.ts @@ -11,10 +11,16 @@ const cliBoolean = z .union([z.boolean(), z.enum(["true", "false"])]) .transform((value) => value === true || value === "true"); +const accountCoordinate = z.union([z.string().email(), z.literal("local")]); + export default defineAction({ - description: "Get all messages in an email thread by thread ID.", + description: + "Read one exact email thread without changing UNREAD or any other mailbox label.", schema: z.object({ - id: z.string().optional().describe("Thread ID"), + accountEmail: accountCoordinate.describe( + 'Connected account email, or "local" for the synthetic mailbox', + ), + id: z.string().min(1).describe("Provider-scoped email thread ID"), compact: cliBoolean.optional().describe("Set to true for compact summary"), }), http: { method: "GET" }, @@ -33,9 +39,9 @@ export default defineAction({ view: "inbox", }; }, - run: async (args) => { - if (!args.id) throw new Error("--id is required"); + run: async (args, ctx) => { const compact = args.compact === true; + const requestedAccount = args.accountEmail.toLowerCase(); const ownerEmail = getRequestUserEmail(); if (!ownerEmail) throw new Error("no authenticated user"); @@ -43,8 +49,20 @@ export default defineAction({ const data = await getUserSetting(ownerEmail, "local-emails"); const emails = data && Array.isArray((data as any).emails) ? (data as any).emails : []; + const localAccounts = new Set( + emails.map( + (email: any) => email.accountEmail?.toLowerCase() ?? "local", + ), + ); + if (!localAccounts.has(requestedAccount)) { + throw new Error("Requested local account is not connected."); + } const messages = emails - .filter((e: any) => e.threadId === args.id) + .filter( + (e: any) => + e.threadId === args.id && + (e.accountEmail?.toLowerCase() ?? "local") === requestedAccount, + ) .sort( (a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime(), @@ -61,56 +79,78 @@ export default defineAction({ date: m.date, })) : messages; - return JSON.stringify(result, null, 2); + return JSON.stringify( + ctx?.caller === "mcp" + ? { + accountEmail: args.accountEmail, + messages: result, + readOnlyGuarantee: { + mailboxLabels: "preserved", + gmailModifyOperations: 0, + }, + } + : result, + null, + 2, + ); } const accounts = await getAccessTokens(); - if (accounts.length === 0) throw new Error("No Google account connected."); - - const labelMap = new Map(); - await Promise.all( - accounts.map(async ({ accessToken }) => { - try { - const map = await fetchLabelMap(accessToken); - for (const [id, name] of map) labelMap.set(id, name); - } catch {} - }), + const account = accounts.find( + ({ email }) => email.toLowerCase() === requestedAccount, ); + if (!account) throw new Error("Requested Google account is not connected."); - for (const { email, accessToken } of accounts) { - try { - const threadRes = await gmailGetThread(accessToken, args.id, "full"); - const messages = (threadRes.messages || []) - .map((m: any) => - gmailToEmailMessage( - { ...m, _accountEmail: email }, - email, - labelMap, - ), - ) - .sort( - (a: any, b: any) => - new Date(a.date).getTime() - new Date(b.date).getTime(), - ); + const labelMap = await fetchLabelMap(account.accessToken); - const result = compact - ? messages.map((m: any) => ({ - id: m.id, - from: m.from.name - ? `${m.from.name} <${m.from.email}>` - : m.from.email, - subject: m.subject, - snippet: m.snippet, - date: m.date, - })) - : messages; + try { + const threadRes = await gmailGetThread( + account.accessToken, + args.id, + "full", + ); + const messages = (threadRes.messages || []) + .map((m: any) => + gmailToEmailMessage( + { ...m, _accountEmail: account.email }, + account.email, + labelMap, + ), + ) + .sort( + (a: any, b: any) => + new Date(a.date).getTime() - new Date(b.date).getTime(), + ); - return JSON.stringify(result, null, 2); - } catch (err: any) { - if (err?.message?.includes("404")) continue; - throw new Error(err?.message ?? "Gmail API error"); - } + const result = compact + ? messages.map((m: any) => ({ + id: m.id, + from: m.from.name + ? `${m.from.name} <${m.from.email}>` + : m.from.email, + subject: m.subject, + snippet: m.snippet, + date: m.date, + })) + : messages; + + return JSON.stringify( + ctx?.caller === "mcp" + ? { + accountEmail: account.email, + messages: result, + readOnlyGuarantee: { + mailboxLabels: "preserved", + gmailModifyOperations: 0, + }, + } + : result, + null, + 2, + ); + } catch (err: any) { + if (err?.message?.includes("404")) throw new Error("Thread not found."); + throw new Error(err?.message ?? "Gmail API error"); } - throw new Error("Thread not found in any connected account."); }, }); diff --git a/templates/mail/server/lib/mail-connector-catalog.spec.ts b/templates/mail/server/lib/mail-connector-catalog.spec.ts index 579b2dfd02..ffcdb2afbc 100644 --- a/templates/mail/server/lib/mail-connector-catalog.spec.ts +++ b/templates/mail/server/lib/mail-connector-catalog.spec.ts @@ -6,33 +6,35 @@ import { describe, expect, it } from "vitest"; import { MAIL_CONNECTOR_CATALOG } from "./mail-connector-catalog"; describe("Mail MCP connector catalog", () => { - it("exposes inventory reads and bounded attachment upload capabilities", () => { + it("exposes bounded inventory, exact body reads, and attachment uploads", () => { expect(MAIL_CONNECTOR_CATALOG).toEqual([ "list-emails", + "get-email", + "get-thread", "create-attachment-upload", ]); expect(MAIL_CONNECTOR_CATALOG).not.toContain("search-emails"); - expect(MAIL_CONNECTOR_CATALOG).not.toContain("get-email"); expect(MAIL_CONNECTOR_CATALOG).not.toContain("send-email"); }); - it("wires the catalog into MCP and keeps email inventory authenticated read-only", () => { + it("wires the catalog into MCP and keeps every email read authenticated read-only", () => { const root = process.cwd(); const plugin = readFileSync( join(root, "server", "plugins", "agent-chat.ts"), "utf8", ); - const action = readFileSync( - join(root, "actions", "list-emails.ts"), - "utf8", - ); - expect(plugin).toContain("connectorCatalog: ["); expect(plugin).toContain("...MAIL_CONNECTOR_CATALOG"); - expect(action).toContain("readOnly: true"); - expect(action).toContain( - "publicAgent: { expose: true, readOnly: true, requiresAuth: true }", - ); + for (const actionName of ["list-emails", "get-email", "get-thread"]) { + const action = readFileSync( + join(root, "actions", `${actionName}.ts`), + "utf8", + ); + expect(action).toContain("readOnly: true"); + expect(action).toContain( + "publicAgent: { expose: true, readOnly: true, requiresAuth: true }", + ); + } }); it("keeps send-email outside the direct connector surface", () => { const uploadAction = readFileSync( diff --git a/templates/mail/server/lib/mail-connector-catalog.ts b/templates/mail/server/lib/mail-connector-catalog.ts index 874d78bceb..a3fd9ada5c 100644 --- a/templates/mail/server/lib/mail-connector-catalog.ts +++ b/templates/mail/server/lib/mail-connector-catalog.ts @@ -1,12 +1,15 @@ /** * Deliberately narrow authenticated MCP surface for Mail. * - * External callers may read inbox coverage and mint a short-lived attachment - * upload capability. Other actions remain available through the in-app agent, - * ask_app, or an explicit full-catalog connection; tool-search alone never - * makes them callable. + * External callers may read inbox coverage, retrieve exact account-scoped + * message or thread bodies, and mint a short-lived attachment upload + * capability. Other actions remain available through the in-app agent, ask_app, + * or an explicit full-catalog connection; tool-search alone never makes them + * callable. */ export const MAIL_CONNECTOR_CATALOG = [ "list-emails", + "get-email", + "get-thread", "create-attachment-upload", ] as const;