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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user