From 50471d63af59915b55595275b86855e5d66937c0 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Tue, 10 Feb 2026 12:01:49 -0800 Subject: [PATCH] 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 --- src/tools/builtin/gmail.test.ts | 130 +++++++++++++++++++++++++++++++- src/tools/builtin/gmail.ts | 103 ++++++++++++++++++++++++- src/tools/policy.ts | 4 +- 3 files changed, 232 insertions(+), 5 deletions(-) diff --git a/src/tools/builtin/gmail.test.ts b/src/tools/builtin/gmail.test.ts index 9886d30..92ece82 100644 --- a/src/tools/builtin/gmail.test.ts +++ b/src/tools/builtin/gmail.test.ts @@ -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 version'; + 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(''); + }); + + it('falls back to stripped HTML when no text/plain part', async () => { + setupValidAuth(); + const htmlBody = '

Amount: $200.00

'; + 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(''); + }); + + 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'); + }); +}); diff --git a/src/tools/builtin/gmail.ts b/src/tools/builtin/gmail.ts index be77b81..44f4632 100644 --- a/src/tools/builtin/gmail.ts +++ b/src/tools/builtin/gmail.ts @@ -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): 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 => { + 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]; } diff --git a/src/tools/policy.ts b/src/tools/policy.ts index bf10314..8ed8e46 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -22,6 +22,7 @@ const PROFILE_TOOLS: Record> = { 'web.search', 'gmail.list', 'gmail.search', + 'gmail.read', 'calendar.today', 'calendar.list', 'calendar.search', @@ -37,6 +38,7 @@ const PROFILE_TOOLS: Record> = { 'web.search', 'gmail.list', 'gmail.search', + 'gmail.read', 'calendar.today', 'calendar.list', 'calendar.search', @@ -67,7 +69,7 @@ export const TOOL_GROUPS: Record = { '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'], };