feat(gmail): add filter creation tool and auth scope
This commit is contained in:
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user