From f04f8a241d69cb5d3a258b459632143fa768f6d3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 22 Feb 2026 22:25:05 -0800 Subject: [PATCH] feat(gmail): add filter creation tool and auth scope --- src/cli/gmail-auth.test.ts | 1 + src/cli/gmail-auth.ts | 5 +- src/tools/builtin/gmail.test.ts | 114 +++++++++++++++- src/tools/builtin/gmail.ts | 234 +++++++++++++++++++++++++++++++- src/tools/policy.test.ts | 25 +++- src/tools/policy.ts | 4 +- 6 files changed, 375 insertions(+), 8 deletions(-) diff --git a/src/cli/gmail-auth.test.ts b/src/cli/gmail-auth.test.ts index 98236c6..83e7f59 100644 --- a/src/cli/gmail-auth.test.ts +++ b/src/cli/gmail-auth.test.ts @@ -72,6 +72,7 @@ describe('gmail-auth', () => { expect(url).toContain('client_id=my-client-id'); expect(url).toContain('redirect_uri=http%3A%2F%2Flocalhost%3A3000'); expect(url).toContain('scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.readonly'); + expect(url).toContain('https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.settings.basic'); expect(url).toContain('access_type=offline'); expect(url).toContain('prompt=consent'); }); diff --git a/src/cli/gmail-auth.ts b/src/cli/gmail-auth.ts index f3eba83..1ffe543 100644 --- a/src/cli/gmail-auth.ts +++ b/src/cli/gmail-auth.ts @@ -6,7 +6,10 @@ import { createServer, type Server } from 'http'; import { URL } from 'url'; import { loadConfigSafe } from './shared.js'; -const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly']; +const SCOPES = [ + 'https://www.googleapis.com/auth/gmail.readonly', + 'https://www.googleapis.com/auth/gmail.settings.basic', +]; const REDIRECT_PORT = 3000; const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}`; diff --git a/src/tools/builtin/gmail.test.ts b/src/tools/builtin/gmail.test.ts index 8cf5718..8d4ac8e 100644 --- a/src/tools/builtin/gmail.test.ts +++ b/src/tools/builtin/gmail.test.ts @@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { GmailConfig } from '../../config/schema.js'; // Hoisted mocks so vi.mock factories can reference them -const { mockMessagesList, mockMessagesGet, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({ +const { mockMessagesList, mockMessagesGet, mockFiltersCreate, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({ mockMessagesList: vi.fn(), mockMessagesGet: vi.fn(), + mockFiltersCreate: vi.fn(), mockExistsSync: vi.fn(), mockReadFileSync: vi.fn(), })); @@ -22,6 +23,11 @@ vi.mock('googleapis', () => ({ list: mockMessagesList, get: mockMessagesGet, }, + settings: { + filters: { + create: mockFiltersCreate, + }, + }, }, }), }, @@ -100,10 +106,10 @@ beforeEach(() => { }); describe('createGmailTools', () => { - it('returns 3 tools with correct names', () => { + it('returns 4 tools with correct names', () => { const tools = createGmailTools(testConfig); - expect(tools).toHaveLength(3); - expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search', 'gmail.read']); + expect(tools).toHaveLength(4); + expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search', 'gmail.read', 'gmail.filter.create']); }); it('tools have descriptions and input schemas', () => { @@ -116,6 +122,106 @@ describe('createGmailTools', () => { }); }); +describe('gmail.filter.create', () => { + it('creates a Gmail filter with criteria + actions', async () => { + setupValidAuth(); + mockFiltersCreate.mockResolvedValue({ + data: { id: 'filter-123' }, + }); + + const [, , , filterCreateTool] = createGmailTools(testConfig); + const result = await filterCreateTool.execute({ + from: 'billing@example.com', + query: 'subject:invoice', + addLabelIds: ['IMPORTANT'], + removeLabelIds: ['INBOX'], + }); + + expect(result.success).toBe(true); + expect(result.output).toContain('Gmail filter created: filter-123'); + expect(result.output).toContain('from=billing@example.com'); + expect(result.output).toContain('query=subject:invoice'); + expect(result.output).toContain('addLabelIds=[IMPORTANT]'); + expect(result.output).toContain('removeLabelIds=[INBOX]'); + + expect(mockFiltersCreate).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'me', + requestBody: expect.objectContaining({ + criteria: expect.objectContaining({ + from: 'billing@example.com', + query: 'subject:invoice', + }), + action: expect.objectContaining({ + addLabelIds: ['IMPORTANT'], + removeLabelIds: ['INBOX'], + }), + }), + }), + ); + }); + + it('returns error when no criteria provided', async () => { + setupValidAuth(); + + const [, , , filterCreateTool] = createGmailTools(testConfig); + const result = await filterCreateTool.execute({ + addLabelIds: ['IMPORTANT'], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('At least one filter criterion is required'); + }); + + it('returns error when no action provided', async () => { + setupValidAuth(); + + const [, , , filterCreateTool] = createGmailTools(testConfig); + const result = await filterCreateTool.execute({ + from: 'news@example.com', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('At least one filter action is required'); + }); + + it('validates size + sizeComparison dependency', async () => { + setupValidAuth(); + const [, , , filterCreateTool] = createGmailTools(testConfig); + + const sizeWithoutComparison = await filterCreateTool.execute({ + from: 'alerts@example.com', + size: 500000, + removeLabelIds: ['INBOX'], + }); + expect(sizeWithoutComparison.success).toBe(false); + expect(sizeWithoutComparison.error).toContain('sizeComparison is required'); + + const comparisonWithoutSize = await filterCreateTool.execute({ + from: 'alerts@example.com', + sizeComparison: 'larger', + removeLabelIds: ['INBOX'], + }); + expect(comparisonWithoutSize.success).toBe(false); + expect(comparisonWithoutSize.error).toContain('size is required'); + }); + + it('surfaces re-auth hint for insufficient scopes', async () => { + setupValidAuth(); + mockFiltersCreate.mockRejectedValue(new Error('Request had insufficient authentication scopes.')); + + const [, , , filterCreateTool] = createGmailTools(testConfig); + const result = await filterCreateTool.execute({ + from: 'alerts@example.com', + removeLabelIds: ['INBOX'], + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('insufficient authentication scopes'); + expect(result.error).toContain('flynn gmail-auth'); + }); +}); + describe('gmail.list', () => { it('returns error when credentials file missing', async () => { mockExistsSync.mockReturnValue(false); diff --git a/src/tools/builtin/gmail.ts b/src/tools/builtin/gmail.ts index e70ae55..2ef67be 100644 --- a/src/tools/builtin/gmail.ts +++ b/src/tools/builtin/gmail.ts @@ -58,6 +58,21 @@ interface EmailSummary { snippet: string; } +interface GmailFilterCreateArgs { + from?: string; + to?: string; + subject?: string; + query?: string; + negatedQuery?: string; + hasAttachment?: boolean; + excludeChats?: boolean; + size?: number; + sizeComparison?: 'larger' | 'smaller'; + addLabelIds?: string[]; + removeLabelIds?: string[]; + forward?: string; +} + /** Fetch metadata for a single message. */ async function fetchMessageDetails( gmail: ReturnType, @@ -143,6 +158,58 @@ function formatEmails(emails: EmailSummary[]): string { .join('\n\n'); } +function formatCreatedFilter(args: GmailFilterCreateArgs, filterId: string): string { + const criteriaParts: string[] = []; + if (args.from) {criteriaParts.push(`from=${args.from}`);} + if (args.to) {criteriaParts.push(`to=${args.to}`);} + if (args.subject) {criteriaParts.push(`subject=${args.subject}`);} + if (args.query) {criteriaParts.push(`query=${args.query}`);} + if (args.negatedQuery) {criteriaParts.push(`negatedQuery=${args.negatedQuery}`);} + if (typeof args.hasAttachment === 'boolean') {criteriaParts.push(`hasAttachment=${args.hasAttachment}`);} + if (typeof args.excludeChats === 'boolean') {criteriaParts.push(`excludeChats=${args.excludeChats}`);} + if (typeof args.size === 'number') { + criteriaParts.push(`size=${args.size}`); + } + if (args.sizeComparison) { + criteriaParts.push(`sizeComparison=${args.sizeComparison}`); + } + + const actionParts: string[] = []; + if (args.addLabelIds && args.addLabelIds.length > 0) { + actionParts.push(`addLabelIds=[${args.addLabelIds.join(', ')}]`); + } + if (args.removeLabelIds && args.removeLabelIds.length > 0) { + actionParts.push(`removeLabelIds=[${args.removeLabelIds.join(', ')}]`); + } + if (args.forward) {actionParts.push(`forward=${args.forward}`);} + + const output = [ + `Gmail filter created: ${filterId}`, + `Criteria: ${criteriaParts.join(', ')}`, + `Action: ${actionParts.join(', ')}`, + ]; + + return output.join('\n'); +} + +function normalizeStringArray(values: unknown): string[] { + if (!Array.isArray(values)) { + return []; + } + + return values + .filter((v): v is string => typeof v === 'string') + .map(v => v.trim()) + .filter(Boolean); +} + +function parseErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + /** * Creates Gmail query tools bound to the given GmailConfig. * Tools create their own OAuth2 client, independent of GmailWatcher. @@ -317,5 +384,170 @@ export function createGmailTools(config: NonNullable): Tool[] { }, }; - return [gmailList, gmailSearch, gmailRead]; + const gmailFilterCreate: Tool = { + name: 'gmail.filter.create', + description: + 'Create a Gmail filter rule using Gmail API criteria and actions (e.g. archive, apply labels, forward). Requires Gmail auth with filter-management scope.', + requiredSecretScopes: ['gmail'], + inputSchema: { + type: 'object', + properties: { + from: { + type: 'string', + description: 'Filter by sender email/address text', + }, + to: { + type: 'string', + description: 'Filter by recipient email/address text', + }, + subject: { + type: 'string', + description: 'Filter by subject text', + }, + query: { + type: 'string', + description: 'Gmail search query (supports operators like is:unread, has:attachment)', + }, + negatedQuery: { + type: 'string', + description: 'Gmail search query that must NOT match', + }, + hasAttachment: { + type: 'boolean', + description: 'Match messages that have attachments', + }, + excludeChats: { + type: 'boolean', + description: 'Exclude Google Chat messages', + }, + size: { + type: 'number', + description: 'Message size threshold in bytes (requires sizeComparison)', + }, + sizeComparison: { + type: 'string', + enum: ['larger', 'smaller'], + description: 'How to compare size threshold', + }, + addLabelIds: { + type: 'array', + description: 'Label IDs to add when matched (system labels like INBOX/IMPORTANT also valid)', + items: { type: 'string' }, + }, + removeLabelIds: { + type: 'array', + description: 'Label IDs to remove when matched (e.g. INBOX to archive)', + items: { type: 'string' }, + }, + forward: { + type: 'string', + description: 'Forward matched mail to this pre-verified forwarding address', + }, + }, + }, + execute: async (rawArgs: unknown): Promise => { + const args = rawArgs as GmailFilterCreateArgs; + + const hasCriteria = Boolean( + args.from?.trim() + || args.to?.trim() + || args.subject?.trim() + || args.query?.trim() + || args.negatedQuery?.trim() + || typeof args.hasAttachment === 'boolean' + || typeof args.excludeChats === 'boolean' + || typeof args.size === 'number' + || args.sizeComparison, + ); + if (!hasCriteria) { + return { + success: false, + output: '', + error: 'At least one filter criterion is required (e.g. from, subject, query, hasAttachment).', + }; + } + + if (typeof args.size === 'number' && !args.sizeComparison) { + return { + success: false, + output: '', + error: 'sizeComparison is required when size is provided ("larger" or "smaller").', + }; + } + + if (args.sizeComparison && typeof args.size !== 'number') { + return { + success: false, + output: '', + error: 'size is required when sizeComparison is provided.', + }; + } + + const addLabelIds = normalizeStringArray(args.addLabelIds); + const removeLabelIds = normalizeStringArray(args.removeLabelIds); + const forward = args.forward?.trim(); + + const hasAction = addLabelIds.length > 0 || removeLabelIds.length > 0 || Boolean(forward); + if (!hasAction) { + return { + success: false, + output: '', + error: 'At least one filter action is required (addLabelIds, removeLabelIds, or forward).', + }; + } + + try { + const auth = createOAuth2Client(config); + const gmail = google.gmail({ version: 'v1', auth }); + + const createResponse = await gmail.users.settings.filters.create({ + userId: 'me', + requestBody: { + criteria: { + from: args.from?.trim(), + to: args.to?.trim(), + subject: args.subject?.trim(), + query: args.query?.trim(), + negatedQuery: args.negatedQuery?.trim(), + hasAttachment: args.hasAttachment, + excludeChats: args.excludeChats, + size: args.size, + sizeComparison: args.sizeComparison, + }, + action: { + addLabelIds: addLabelIds.length > 0 ? addLabelIds : undefined, + removeLabelIds: removeLabelIds.length > 0 ? removeLabelIds : undefined, + forward, + }, + }, + }); + + const filterId = createResponse.data.id ?? 'unknown-id'; + return { + success: true, + output: formatCreatedFilter( + { + ...args, + addLabelIds, + removeLabelIds, + forward, + }, + filterId, + ), + }; + } catch (error) { + const message = parseErrorMessage(error); + const needsScopeHint = /insufficient.*scope|insufficientPermissions|Request had insufficient authentication scopes/i.test(message); + return { + success: false, + output: '', + error: needsScopeHint + ? `${message}. Re-run "flynn gmail-auth" to grant Gmail filter-management permissions.` + : message, + }; + } + }, + }; + + return [gmailList, gmailSearch, gmailRead, gmailFilterCreate]; } diff --git a/src/tools/policy.test.ts b/src/tools/policy.test.ts index 6c5ef2c..543df01 100644 --- a/src/tools/policy.test.ts +++ b/src/tools/policy.test.ts @@ -18,6 +18,10 @@ const ALL_TOOL_NAMES = [ 'memory.read', 'memory.write', 'memory.search', + 'gmail.list', + 'gmail.search', + 'gmail.read', + 'gmail.filter.create', 'minio.share', 'minio.ingest', 'minio.sync', @@ -146,7 +150,7 @@ describe('ToolPolicy', () => { expect(names).not.toContain('mcp:filesystem:read_file'); }); - it('messaging profile includes memory and web search', () => { + it('messaging profile includes memory, web search, and gmail tools', () => { const policy = new ToolPolicy(defaultConfig({ profile: 'messaging' })); const result = policy.filterTools(ALL_TOOLS); const names = result.map(t => t.name); @@ -155,6 +159,10 @@ describe('ToolPolicy', () => { expect(names).toContain('memory.write'); expect(names).toContain('web.search'); expect(names).toContain('web.search.news'); + expect(names).toContain('gmail.list'); + expect(names).toContain('gmail.search'); + expect(names).toContain('gmail.read'); + expect(names).toContain('gmail.filter.create'); expect(names).not.toContain('shell.exec'); expect(names).not.toContain('file.write'); }); @@ -503,6 +511,21 @@ describe('ToolPolicy', () => { expect(names).not.toContain('shell.exec'); }); + it('expands group:gmail', () => { + const policy = new ToolPolicy(defaultConfig({ + profile: 'minimal', + allow: ['group:gmail'], + })); + const result = policy.filterTools(ALL_TOOLS); + const names = result.map(t => t.name); + expect(names).toContain('gmail.list'); + expect(names).toContain('gmail.search'); + expect(names).toContain('gmail.read'); + expect(names).toContain('gmail.filter.create'); + expect(names).toContain('file.read'); + expect(names).not.toContain('shell.exec'); + }); + it('expands group:k8s', () => { const policy = new ToolPolicy(defaultConfig({ profile: 'minimal', diff --git a/src/tools/policy.ts b/src/tools/policy.ts index 072f30f..ea49e99 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -25,6 +25,7 @@ const PROFILE_TOOLS: Record> = { 'gmail.list', 'gmail.search', 'gmail.read', + 'gmail.filter.create', 'calendar.today', 'calendar.list', 'calendar.search', @@ -63,6 +64,7 @@ const PROFILE_TOOLS: Record> = { 'gmail.list', 'gmail.search', 'gmail.read', + 'gmail.filter.create', 'calendar.today', 'calendar.list', 'calendar.search', @@ -117,7 +119,7 @@ export const TOOL_GROUPS: Record = { 'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list', 'screen.capture', 'camera.capture'], 'group:web': ['web.fetch', 'web.search', 'web.search.news', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval', 'browser.evaluate'], 'group:memory': ['memory.read', 'memory.write', 'memory.search'], - 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read'], + 'group:gmail': ['gmail.list', 'gmail.search', 'gmail.read', 'gmail.filter.create'], 'group:gcal': ['calendar.today', 'calendar.list', 'calendar.search'], 'group:gdocs': ['docs.list', 'docs.search', 'docs.read'], 'group:gdrive': ['drive.list', 'drive.search', 'drive.read'],