feat(tools): add gmail.read tool for full email content
The existing gmail.list and gmail.search tools only return snippets. gmail.read fetches the full message by ID using format: 'full', decodes base64url body parts (preferring text/plain, falling back to stripped HTML), and returns headers + body text. This enables workflows like searching for invoices and extracting amounts from the full content. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -97,10 +97,10 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('createGmailTools', () => {
|
||||
it('returns 2 tools with correct names', () => {
|
||||
it('returns 3 tools with correct names', () => {
|
||||
const tools = createGmailTools(testConfig);
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search']);
|
||||
expect(tools).toHaveLength(3);
|
||||
expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search', 'gmail.read']);
|
||||
});
|
||||
|
||||
it('tools have descriptions and input schemas', () => {
|
||||
@@ -257,3 +257,127 @@ describe('gmail.search', () => {
|
||||
expect(result.error).toContain('API quota exceeded');
|
||||
});
|
||||
});
|
||||
|
||||
// ── gmail.read ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Encode a string as base64url (matching Gmail API format). */
|
||||
function toBase64Url(str: string): string {
|
||||
return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
describe('gmail.read', () => {
|
||||
it('reads full message with plain text body', async () => {
|
||||
setupValidAuth();
|
||||
const bodyText = 'Hello, this is the full email body with invoice amount $150.00';
|
||||
mockMessagesGet.mockResolvedValue({
|
||||
data: {
|
||||
payload: {
|
||||
mimeType: 'text/plain',
|
||||
headers: [
|
||||
{ name: 'From', value: 'billing@example.com' },
|
||||
{ name: 'To', value: 'will@example.com' },
|
||||
{ name: 'Subject', value: 'Payment Receipt' },
|
||||
{ name: 'Date', value: 'Mon, 10 Feb 2026 12:00:00 -0000' },
|
||||
],
|
||||
body: { data: toBase64Url(bodyText) },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [, , readTool] = createGmailTools(testConfig);
|
||||
const result = await readTool.execute({ id: 'msg123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('From: billing@example.com');
|
||||
expect(result.output).toContain('To: will@example.com');
|
||||
expect(result.output).toContain('Subject: Payment Receipt');
|
||||
expect(result.output).toContain('$150.00');
|
||||
|
||||
expect(mockMessagesGet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 'me',
|
||||
id: 'msg123',
|
||||
format: 'full',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads multipart message preferring text/plain', async () => {
|
||||
setupValidAuth();
|
||||
const plainBody = 'Plain text version of the email';
|
||||
const htmlBody = '<html><body><b>HTML version</b></body></html>';
|
||||
mockMessagesGet.mockResolvedValue({
|
||||
data: {
|
||||
payload: {
|
||||
mimeType: 'multipart/alternative',
|
||||
headers: [
|
||||
{ name: 'From', value: 'sender@example.com' },
|
||||
{ name: 'To', value: 'will@example.com' },
|
||||
{ name: 'Subject', value: 'Multipart Test' },
|
||||
{ name: 'Date', value: 'Mon, 10 Feb 2026 12:00:00 -0000' },
|
||||
],
|
||||
parts: [
|
||||
{ mimeType: 'text/html', body: { data: toBase64Url(htmlBody) } },
|
||||
{ mimeType: 'text/plain', body: { data: toBase64Url(plainBody) } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [, , readTool] = createGmailTools(testConfig);
|
||||
const result = await readTool.execute({ id: 'msg456' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Plain text version of the email');
|
||||
expect(result.output).not.toContain('<html>');
|
||||
});
|
||||
|
||||
it('falls back to stripped HTML when no text/plain part', async () => {
|
||||
setupValidAuth();
|
||||
const htmlBody = '<html><body><p>Amount: $200.00</p></body></html>';
|
||||
mockMessagesGet.mockResolvedValue({
|
||||
data: {
|
||||
payload: {
|
||||
mimeType: 'multipart/alternative',
|
||||
headers: [
|
||||
{ name: 'From', value: 'sender@example.com' },
|
||||
{ name: 'To', value: 'will@example.com' },
|
||||
{ name: 'Subject', value: 'HTML Only' },
|
||||
{ name: 'Date', value: 'Mon, 10 Feb 2026 12:00:00 -0000' },
|
||||
],
|
||||
parts: [
|
||||
{ mimeType: 'text/html', body: { data: toBase64Url(htmlBody) } },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [, , readTool] = createGmailTools(testConfig);
|
||||
const result = await readTool.execute({ id: 'msg789' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toContain('Amount: $200.00');
|
||||
expect(result.output).not.toContain('<html>');
|
||||
});
|
||||
|
||||
it('returns error when credentials missing', async () => {
|
||||
mockExistsSync.mockReturnValue(false);
|
||||
const [, , readTool] = createGmailTools(testConfig);
|
||||
|
||||
const result = await readTool.execute({ id: 'msg1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Credentials file not found');
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
setupValidAuth();
|
||||
mockMessagesGet.mockRejectedValue(new Error('Message not found'));
|
||||
|
||||
const [, , readTool] = createGmailTools(testConfig);
|
||||
const result = await readTool.execute({ id: 'nonexistent' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Message not found');
|
||||
});
|
||||
});
|
||||
|
||||
+102
-1
@@ -86,6 +86,51 @@ async function fetchMessageDetails(
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a base64url-encoded string. */
|
||||
function decodeBase64Url(data: string): string {
|
||||
return Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8');
|
||||
}
|
||||
|
||||
/** Extract plain text body from a message payload, walking MIME parts recursively. */
|
||||
function extractTextBody(payload: {
|
||||
mimeType?: string | null;
|
||||
body?: { data?: string | null } | null;
|
||||
parts?: Array<{
|
||||
mimeType?: string | null;
|
||||
body?: { data?: string | null } | null;
|
||||
parts?: unknown[];
|
||||
}> | null;
|
||||
}): string {
|
||||
// Direct body (simple messages)
|
||||
if (payload.mimeType === 'text/plain' && payload.body?.data) {
|
||||
return decodeBase64Url(payload.body.data);
|
||||
}
|
||||
|
||||
// Walk parts for multipart messages — prefer text/plain over text/html
|
||||
if (payload.parts) {
|
||||
let htmlFallback = '';
|
||||
for (const part of payload.parts) {
|
||||
if (part.mimeType === 'text/plain' && part.body?.data) {
|
||||
return decodeBase64Url(part.body.data);
|
||||
}
|
||||
if (part.mimeType === 'text/html' && part.body?.data) {
|
||||
htmlFallback = decodeBase64Url(part.body.data);
|
||||
}
|
||||
// Recurse into nested multipart
|
||||
if (part.parts) {
|
||||
const nested = extractTextBody(part as typeof payload);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
if (htmlFallback) {
|
||||
// Strip HTML tags for a rough plain-text rendering
|
||||
return htmlFallback.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Format a list of email summaries for tool output. */
|
||||
function formatEmails(emails: EmailSummary[]): string {
|
||||
if (emails.length === 0) {
|
||||
@@ -212,5 +257,61 @@ export function createGmailTools(config: NonNullable<GmailConfig>): Tool[] {
|
||||
},
|
||||
};
|
||||
|
||||
return [gmailList, gmailSearch];
|
||||
const gmailRead: Tool = {
|
||||
name: 'gmail.read',
|
||||
description:
|
||||
'Read the full content of a Gmail message by ID. Returns headers (from, to, subject, date) and the full text body. Use gmail.list or gmail.search first to get message IDs.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'The Gmail message ID (from gmail.list or gmail.search results)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||
const args = rawArgs as { id: string };
|
||||
|
||||
try {
|
||||
const auth = createOAuth2Client(config);
|
||||
const gmail = google.gmail({ version: 'v1', auth });
|
||||
|
||||
const msg = await gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: args.id,
|
||||
format: 'full',
|
||||
});
|
||||
|
||||
const headers = msg.data.payload?.headers ?? [];
|
||||
const getHeader = (name: string): string =>
|
||||
headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
|
||||
|
||||
const body = msg.data.payload ? extractTextBody(msg.data.payload) : '';
|
||||
|
||||
const parts = [
|
||||
`From: ${getHeader('From')}`,
|
||||
`To: ${getHeader('To')}`,
|
||||
`Subject: ${getHeader('Subject')}`,
|
||||
`Date: ${getHeader('Date')}`,
|
||||
'',
|
||||
body || '(no text content)',
|
||||
];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: parts.join('\n'),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
output: '',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return [gmailList, gmailSearch, gmailRead];
|
||||
}
|
||||
|
||||
+3
-1
@@ -22,6 +22,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
||||
'web.search',
|
||||
'gmail.list',
|
||||
'gmail.search',
|
||||
'gmail.read',
|
||||
'calendar.today',
|
||||
'calendar.list',
|
||||
'calendar.search',
|
||||
@@ -37,6 +38,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
||||
'web.search',
|
||||
'gmail.list',
|
||||
'gmail.search',
|
||||
'gmail.read',
|
||||
'calendar.today',
|
||||
'calendar.list',
|
||||
'calendar.search',
|
||||
@@ -67,7 +69,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
|
||||
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'],
|
||||
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'],
|
||||
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
||||
'group:gmail': ['gmail.list', 'gmail.search'],
|
||||
'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'],
|
||||
'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user