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');
});
});
+102 -1
View File
@@ -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
View File
@@ -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'],
};