From f085b0619085223aefb2eceb6ba9d82dd5067599 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:46:14 -0400 Subject: [PATCH 1/2] Expose exact read-only Mail body actions --- templates/mail/actions/get-email.ts | 83 ++++++-- .../mail/actions/get-mail-content.spec.ts | 198 ++++++++++++++++++ templates/mail/actions/get-thread.ts | 137 +++++++----- .../server/lib/mail-connector-catalog.spec.ts | 26 +-- .../mail/server/lib/mail-connector-catalog.ts | 11 +- 5 files changed, 373 insertions(+), 82 deletions(-) create mode 100644 templates/mail/actions/get-mail-content.spec.ts diff --git a/templates/mail/actions/get-email.ts b/templates/mail/actions/get-email.ts index 2765a85e5f..1d52c520d6 100644 --- a/templates/mail/actions/get-email.ts +++ b/templates/mail/actions/get-email.ts @@ -9,13 +9,19 @@ import { getAccessTokens, fetchLabelMap } from "./helpers.js"; 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: z + .string() + .email() + .describe("Connected account that owns the provider-scoped message ID"), + 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 +29,66 @@ 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()) + .filter(Boolean), + ); + if (localAccounts.size === 0) localAccounts.add(ownerEmail.toLowerCase()); + 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 || + e.accountEmail.toLowerCase() === 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, + preservation: { + 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, + preservation: { + 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..0faccd47ed --- /dev/null +++ b/templates/mail/actions/get-mail-content.spec.ts @@ -0,0 +1,198 @@ +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); + }); + + 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 }, + preservation: { + 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 }, + ], + preservation: { + 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("does not expose a synthetic mailbox under an arbitrary account", async () => { + mocks.isConnected.mockResolvedValue(false); + mocks.getUserSetting.mockResolvedValue({ + emails: [rawMessage("message-1", "thread-1")], + }); + + await expect( + getEmail.run({ accountEmail: OTHER, id: "message-1" }), + ).rejects.toThrow("Requested local account is not connected."); + await expect( + getThread.run({ accountEmail: OTHER, id: "thread-1" }), + ).rejects.toThrow("Requested local account is not connected."); + + 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..8748527073 100644 --- a/templates/mail/actions/get-thread.ts +++ b/templates/mail/actions/get-thread.ts @@ -12,9 +12,14 @@ const cliBoolean = z .transform((value) => value === true || value === "true"); 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: z + .string() + .email() + .describe("Connected account that owns the provider-scoped thread ID"), + 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 +38,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 +48,22 @@ 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()) + .filter(Boolean), + ); + if (localAccounts.size === 0) localAccounts.add(ownerEmail.toLowerCase()); + 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 || + e.accountEmail.toLowerCase() === requestedAccount), + ) .sort( (a: any, b: any) => new Date(a.date).getTime() - new Date(b.date).getTime(), @@ -61,56 +80,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, + preservation: { + 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, + preservation: { + 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; From 265fb382f53f062dc001a0fe3646db697276f975 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:52:31 -0400 Subject: [PATCH 2/2] Fix synthetic Mail account isolation --- templates/mail/actions/get-email.ts | 23 ++++---- .../mail/actions/get-mail-content.spec.ts | 57 ++++++++++++++++--- templates/mail/actions/get-thread.ts | 23 ++++---- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/templates/mail/actions/get-email.ts b/templates/mail/actions/get-email.ts index 1d52c520d6..91d0b4f376 100644 --- a/templates/mail/actions/get-email.ts +++ b/templates/mail/actions/get-email.ts @@ -7,14 +7,15 @@ 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: "Read one exact email, including its full body and metadata, without changing UNREAD or any other mailbox label.", schema: z.object({ - accountEmail: z - .string() - .email() - .describe("Connected account that owns the provider-scoped 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" }, @@ -30,19 +31,17 @@ export default defineAction({ const emails = data && Array.isArray((data as any).emails) ? (data as any).emails : []; const localAccounts = new Set( - emails - .map((email: any) => email.accountEmail?.toLowerCase()) - .filter(Boolean), + emails.map( + (email: any) => email.accountEmail?.toLowerCase() ?? "local", + ), ); - if (localAccounts.size === 0) localAccounts.add(ownerEmail.toLowerCase()); 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 || - e.accountEmail.toLowerCase() === requestedAccount), + (e.accountEmail?.toLowerCase() ?? "local") === requestedAccount, ); if (!found) throw new Error("Email not found."); const email = { ...found, accountEmail: args.accountEmail }; @@ -51,7 +50,7 @@ export default defineAction({ ? { accountEmail: args.accountEmail, email, - preservation: { + readOnlyGuarantee: { mailboxLabels: "preserved", gmailModifyOperations: 0, }, @@ -77,7 +76,7 @@ export default defineAction({ ? { accountEmail: account.email, email, - preservation: { + readOnlyGuarantee: { mailboxLabels: "preserved", gmailModifyOperations: 0, }, diff --git a/templates/mail/actions/get-mail-content.spec.ts b/templates/mail/actions/get-mail-content.spec.ts index 0faccd47ed..7070a84d95 100644 --- a/templates/mail/actions/get-mail-content.spec.ts +++ b/templates/mail/actions/get-mail-content.spec.ts @@ -83,6 +83,14 @@ describe("exact Mail body reads", () => { 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 () => { @@ -109,7 +117,7 @@ describe("exact Mail body reads", () => { expect(result).toMatchObject({ accountEmail: OWNER, email: { id: message.id, accountEmail: OWNER }, - preservation: { + readOnlyGuarantee: { mailboxLabels: "preserved", gmailModifyOperations: 0, }, @@ -148,7 +156,7 @@ describe("exact Mail body reads", () => { { id: "message-1", accountEmail: OTHER }, { id: "message-2", accountEmail: OTHER }, ], - preservation: { + readOnlyGuarantee: { mailboxLabels: "preserved", gmailModifyOperations: 0, }, @@ -178,18 +186,53 @@ describe("exact Mail body reads", () => { expect(mocks.gmailModifyThread).not.toHaveBeenCalled(); }); - it("does not expose a synthetic mailbox under an arbitrary account", async () => { + 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: "message-1" }), - ).rejects.toThrow("Requested local account is not connected."); + getEmail.run({ accountEmail: OTHER, id: "local-message" }), + ).rejects.toThrow("Email not found."); await expect( - getThread.run({ accountEmail: OTHER, id: "thread-1" }), - ).rejects.toThrow("Requested local account is not connected."); + getThread.run({ accountEmail: OTHER, id: "local-thread" }), + ).rejects.toThrow("Thread not found."); expect(mocks.getAccessTokens).not.toHaveBeenCalled(); expect(mocks.gmailGetMessage).not.toHaveBeenCalled(); diff --git a/templates/mail/actions/get-thread.ts b/templates/mail/actions/get-thread.ts index 8748527073..2ea76a7fc6 100644 --- a/templates/mail/actions/get-thread.ts +++ b/templates/mail/actions/get-thread.ts @@ -11,14 +11,15 @@ 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: "Read one exact email thread without changing UNREAD or any other mailbox label.", schema: z.object({ - accountEmail: z - .string() - .email() - .describe("Connected account that owns the provider-scoped 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"), }), @@ -49,11 +50,10 @@ export default defineAction({ const emails = data && Array.isArray((data as any).emails) ? (data as any).emails : []; const localAccounts = new Set( - emails - .map((email: any) => email.accountEmail?.toLowerCase()) - .filter(Boolean), + emails.map( + (email: any) => email.accountEmail?.toLowerCase() ?? "local", + ), ); - if (localAccounts.size === 0) localAccounts.add(ownerEmail.toLowerCase()); if (!localAccounts.has(requestedAccount)) { throw new Error("Requested local account is not connected."); } @@ -61,8 +61,7 @@ export default defineAction({ .filter( (e: any) => e.threadId === args.id && - (!e.accountEmail || - e.accountEmail.toLowerCase() === requestedAccount), + (e.accountEmail?.toLowerCase() ?? "local") === requestedAccount, ) .sort( (a: any, b: any) => @@ -85,7 +84,7 @@ export default defineAction({ ? { accountEmail: args.accountEmail, messages: result, - preservation: { + readOnlyGuarantee: { mailboxLabels: "preserved", gmailModifyOperations: 0, }, @@ -140,7 +139,7 @@ export default defineAction({ ? { accountEmail: account.email, messages: result, - preservation: { + readOnlyGuarantee: { mailboxLabels: "preserved", gmailModifyOperations: 0, },