Skip to content
Draft
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
82 changes: 64 additions & 18 deletions templates/mail/actions/get-email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,87 @@ 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");
if (!(await isConnected(ownerEmail))) {
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.");
},
});
241 changes: 241 additions & 0 deletions templates/mail/actions/get-mail-content.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading