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:
William Valentin
2026-02-10 12:01:49 -08:00
parent d39d3ac367
commit 50471d63af
3 changed files with 232 additions and 5 deletions
+127 -3
View File
@@ -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');
});
});