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