feat(gmail): add filter creation tool and auth scope

This commit is contained in:
William Valentin
2026-02-22 22:25:05 -08:00
parent eb81e68dd8
commit f04f8a241d
6 changed files with 375 additions and 8 deletions
+110 -4
View File
@@ -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);
+233 -1
View File
@@ -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<typeof google.gmail>,
@@ -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<GmailConfig>): 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<ToolResult> => {
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];
}
+24 -1
View File
@@ -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',
+3 -1
View File
@@ -25,6 +25,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'gmail.list',
'gmail.search',
'gmail.read',
'gmail.filter.create',
'calendar.today',
'calendar.list',
'calendar.search',
@@ -63,6 +64,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
'gmail.list',
'gmail.search',
'gmail.read',
'gmail.filter.create',
'calendar.today',
'calendar.list',
'calendar.search',
@@ -117,7 +119,7 @@ export const TOOL_GROUPS: Record<string, string[]> = {
'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'],