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