feat(tools): add Google Calendar tools and register Gmail/GCal in daemon

Add calendar.today, calendar.list, calendar.search tools mirroring the
Gmail tool pattern. Includes gcal-auth CLI command, config schema, tool
policy entries (messaging/coding profiles + group:gcal), and 17 tests.
Also wires up gmail and gcal tool registration in the daemon and TUI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
William Valentin
2026-02-10 11:40:53 -08:00
parent 4cc29f534a
commit 94264e848c
11 changed files with 1386 additions and 2 deletions
+355
View File
@@ -0,0 +1,355 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { GcalConfig } from '../../config/schema.js';
// Hoisted mocks so vi.mock factories can reference them
const { mockEventsList, mockExistsSync, mockReadFileSync } = vi.hoisted(() => ({
mockEventsList: vi.fn(),
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
}));
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
calendar: vi.fn().mockReturnValue({
events: {
list: mockEventsList,
},
}),
},
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
};
});
import { createGcalTools } from './gcal.js';
// ── Test config ─────────────────────────────────────────────────────────────
const testConfig: NonNullable<GcalConfig> = {
enabled: true,
credentials_file: '/tmp/test-creds.json',
token_file: '/tmp/test-token.json',
calendar_ids: ['primary'],
};
const fakeCredentials = {
installed: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uris: ['http://localhost'],
},
};
const fakeToken = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function setupValidAuth() {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation((path: unknown) => {
const p = String(path);
if (p.includes('creds')) return JSON.stringify(fakeCredentials);
if (p.includes('token')) return JSON.stringify(fakeToken);
return '';
});
}
function mockCalendarEvent(
id: string,
summary: string,
startTime: string,
endTime: string,
opts?: { location?: string; attendees?: string[]; htmlLink?: string },
) {
return {
id,
summary,
start: { dateTime: startTime },
end: { dateTime: endTime },
location: opts?.location,
attendees: opts?.attendees?.map(email => ({ email })),
htmlLink: opts?.htmlLink ?? `https://calendar.google.com/event/${id}`,
};
}
// ═════════════════════════════════════════════════════════════════════════════
beforeEach(() => {
vi.clearAllMocks();
});
describe('createGcalTools', () => {
it('returns 3 tools with correct names', () => {
const tools = createGcalTools(testConfig);
expect(tools).toHaveLength(3);
expect(tools.map(t => t.name)).toEqual(['calendar.today', 'calendar.list', 'calendar.search']);
});
it('tools have descriptions and input schemas', () => {
const tools = createGcalTools(testConfig);
for (const tool of tools) {
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe('object');
}
});
});
describe('calendar.today', () => {
it('returns error when credentials file missing', async () => {
mockExistsSync.mockReturnValue(false);
const [todayTool] = createGcalTools(testConfig);
const result = await todayTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('returns error when token file missing', async () => {
mockExistsSync.mockImplementation((path: unknown) => {
return String(path).includes('creds');
});
mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials));
const [todayTool] = createGcalTools(testConfig);
const result = await todayTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Token file not found');
});
it('lists today\'s events', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({
data: {
items: [
mockCalendarEvent('ev1', 'Team standup', '2026-02-10T09:00:00Z', '2026-02-10T09:30:00Z', {
location: 'Room A',
attendees: ['alice@test.com', 'bob@test.com'],
}),
mockCalendarEvent('ev2', 'Lunch', '2026-02-10T12:00:00Z', '2026-02-10T13:00:00Z'),
],
},
});
const [todayTool] = createGcalTools(testConfig);
const result = await todayTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('Team standup');
expect(result.output).toContain('Room A');
expect(result.output).toContain('alice@test.com');
expect(result.output).toContain('Lunch');
expect(mockEventsList).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: 'primary',
singleEvents: true,
orderBy: 'startTime',
}),
);
});
it('handles empty results', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({ data: { items: [] } });
const [todayTool] = createGcalTools(testConfig);
const result = await todayTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No events found.');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockEventsList.mockRejectedValue(new Error('API quota exceeded'));
const [todayTool] = createGcalTools(testConfig);
const result = await todayTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
it('respects calendarId parameter', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({ data: { items: [] } });
const [todayTool] = createGcalTools(testConfig);
await todayTool.execute({ calendarId: 'work@group.calendar.google.com' });
expect(mockEventsList).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: 'work@group.calendar.google.com',
}),
);
});
});
describe('calendar.list', () => {
it('queries events in date range', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({
data: {
items: [
mockCalendarEvent('ev1', 'Conference', '2026-02-15T10:00:00Z', '2026-02-15T17:00:00Z'),
],
},
});
const [, listTool] = createGcalTools(testConfig);
const result = await listTool.execute({ startDate: '2026-02-15', endDate: '2026-02-16' });
expect(result.success).toBe(true);
expect(result.output).toContain('Conference');
expect(mockEventsList).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: 'primary',
singleEvents: true,
orderBy: 'startTime',
}),
);
});
it('returns error for invalid dates', async () => {
setupValidAuth();
const [, listTool] = createGcalTools(testConfig);
const result = await listTool.execute({ startDate: 'not-a-date', endDate: '2026-02-10' });
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid date format');
});
it('respects maxResults param', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({ data: { items: [] } });
const [, listTool] = createGcalTools(testConfig);
await listTool.execute({ startDate: '2026-02-10', endDate: '2026-02-11', maxResults: 5 });
expect(mockEventsList).toHaveBeenCalledWith(
expect.objectContaining({
maxResults: 5,
}),
);
});
it('respects calendarId param', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({ data: { items: [] } });
const [, listTool] = createGcalTools(testConfig);
await listTool.execute({ startDate: '2026-02-10', endDate: '2026-02-11', calendarId: 'work@group.calendar.google.com' });
expect(mockEventsList).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: 'work@group.calendar.google.com',
}),
);
});
});
describe('calendar.search', () => {
it('searches with query parameter', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({
data: {
items: [
mockCalendarEvent('ev1', 'Team planning', '2026-02-10T14:00:00Z', '2026-02-10T15:00:00Z'),
],
},
});
const [, , searchTool] = createGcalTools(testConfig);
const result = await searchTool.execute({ query: 'planning' });
expect(result.success).toBe(true);
expect(result.output).toContain('Team planning');
expect(mockEventsList).toHaveBeenCalledWith(
expect.objectContaining({
q: 'planning',
singleEvents: true,
orderBy: 'startTime',
}),
);
});
it('respects maxResults param', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({ data: { items: [] } });
const [, , searchTool] = createGcalTools(testConfig);
await searchTool.execute({ query: 'meeting', maxResults: 3 });
expect(mockEventsList).toHaveBeenCalledWith(
expect.objectContaining({
q: 'meeting',
maxResults: 3,
}),
);
});
it('returns error when credentials missing', async () => {
mockExistsSync.mockReturnValue(false);
const [, , searchTool] = createGcalTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockEventsList.mockRejectedValue(new Error('API quota exceeded'));
const [, , searchTool] = createGcalTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
it('formats events with all fields', async () => {
setupValidAuth();
mockEventsList.mockResolvedValue({
data: {
items: [
mockCalendarEvent('ev1', 'Board meeting', '2026-03-01T10:00:00Z', '2026-03-01T12:00:00Z', {
location: 'HQ Conference Room',
attendees: ['ceo@company.com', 'cfo@company.com'],
htmlLink: 'https://calendar.google.com/event/ev1',
}),
],
},
});
const [, , searchTool] = createGcalTools(testConfig);
const result = await searchTool.execute({ query: 'board' });
expect(result.success).toBe(true);
expect(result.output).toContain('Board meeting');
expect(result.output).toContain('HQ Conference Room');
expect(result.output).toContain('ceo@company.com');
expect(result.output).toContain('cfo@company.com');
expect(result.output).toContain('https://calendar.google.com/event/ev1');
});
});
+278
View File
@@ -0,0 +1,278 @@
import { google, type Auth } from 'googleapis';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import type { GcalConfig } from '../../config/schema.js';
import type { Tool, ToolResult } from '../types.js';
/** Expand ~ to home directory. */
function expandPath(p: string): string {
if (p.startsWith('~/') || p === '~') {
return resolve(homedir(), p.slice(2));
}
return resolve(p);
}
/** Create an OAuth2 client from Google Calendar config (credentials + token files). */
function createOAuth2Client(config: NonNullable<GcalConfig>): Auth.OAuth2Client {
const credentialsPath = config.credentials_file;
if (!credentialsPath) {
throw new Error('No credentials_file configured. Set automation.gcal.credentials_file in config.');
}
const expandedCredsPath = expandPath(credentialsPath);
if (!existsSync(expandedCredsPath)) {
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
}
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {};
if (!client_id || !client_secret) {
throw new Error('Invalid credentials file — missing client_id or client_secret');
}
const oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris?.[0] ?? 'http://localhost',
);
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gcal-token.json');
if (!existsSync(tokenPath)) {
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gcal-auth" to authenticate.`);
}
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
oauth2Client.setCredentials(token);
return oauth2Client;
}
interface EventSummary {
id: string;
summary: string;
start: string;
end: string;
location: string;
attendees: string[];
htmlLink: string;
}
/** Fetch events from Google Calendar. */
async function fetchEvents(
calendar: ReturnType<typeof google.calendar>,
params: {
calendarId?: string;
timeMin?: string;
timeMax?: string;
q?: string;
maxResults?: number;
},
): Promise<EventSummary[]> {
const response = await calendar.events.list({
calendarId: params.calendarId ?? 'primary',
timeMin: params.timeMin,
timeMax: params.timeMax,
q: params.q,
maxResults: params.maxResults ?? 25,
singleEvents: true,
orderBy: 'startTime',
});
const items = response.data.items ?? [];
return items.map(event => ({
id: event.id ?? '',
summary: event.summary ?? '(no title)',
start: event.start?.dateTime ?? event.start?.date ?? '',
end: event.end?.dateTime ?? event.end?.date ?? '',
location: event.location ?? '',
attendees: (event.attendees ?? []).map(a => a.email ?? '').filter(Boolean),
htmlLink: event.htmlLink ?? '',
}));
}
/** Format a list of event summaries for tool output. */
function formatEvents(events: EventSummary[]): string {
if (events.length === 0) {
return 'No events found.';
}
return events
.map(e => {
const parts = [`[${e.id}] ${e.summary}`, ` Time: ${e.start}${e.end}`];
if (e.location) parts.push(` Location: ${e.location}`);
if (e.attendees.length > 0) parts.push(` Attendees: ${e.attendees.join(', ')}`);
if (e.htmlLink) parts.push(` Link: ${e.htmlLink}`);
return parts.join('\n');
})
.join('\n\n');
}
/**
* Creates Google Calendar query tools bound to the given GcalConfig.
* Tools create their own OAuth2 client per invocation.
*/
export function createGcalTools(config: NonNullable<GcalConfig>): Tool[] {
const calendarToday: Tool = {
name: 'calendar.today',
description:
"List today's events from Google Calendar. Returns summary, time, location, attendees, and link for each event.",
inputSchema: {
type: 'object',
properties: {
calendarId: {
type: 'string',
description: 'Calendar ID to query (default: primary)',
},
},
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { calendarId?: string };
try {
const auth = createOAuth2Client(config);
const calendar = google.calendar({ version: 'v3', auth });
const now = new Date();
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
const events = await fetchEvents(calendar, {
calendarId: args.calendarId,
timeMin: startOfDay.toISOString(),
timeMax: endOfDay.toISOString(),
});
return {
success: true,
output: formatEvents(events),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const calendarList: Tool = {
name: 'calendar.list',
description:
'List events from Google Calendar in a date range. Returns summary, time, location, attendees, and link for each event.',
inputSchema: {
type: 'object',
properties: {
startDate: {
type: 'string',
description: 'Start date in ISO format (e.g. 2026-02-10)',
},
endDate: {
type: 'string',
description: 'End date in ISO format (e.g. 2026-02-14)',
},
calendarId: {
type: 'string',
description: 'Calendar ID to query (default: primary)',
},
maxResults: {
type: 'number',
description: 'Maximum number of events to return (default: 25)',
},
},
required: ['startDate', 'endDate'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { startDate: string; endDate: string; calendarId?: string; maxResults?: number };
try {
const startMs = Date.parse(args.startDate);
const endMs = Date.parse(args.endDate);
if (isNaN(startMs) || isNaN(endMs)) {
return { success: false, output: '', error: 'Invalid date format. Use ISO format (e.g. 2026-02-10).' };
}
const auth = createOAuth2Client(config);
const calendar = google.calendar({ version: 'v3', auth });
const events = await fetchEvents(calendar, {
calendarId: args.calendarId,
timeMin: new Date(startMs).toISOString(),
timeMax: new Date(endMs).toISOString(),
maxResults: args.maxResults,
});
return {
success: true,
output: formatEvents(events),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const calendarSearch: Tool = {
name: 'calendar.search',
description:
'Search Google Calendar events by text query. Returns summary, time, location, attendees, and link for each match.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Full-text search query',
},
calendarId: {
type: 'string',
description: 'Calendar ID to query (default: primary)',
},
maxResults: {
type: 'number',
description: 'Maximum number of results to return (default: 25)',
},
},
required: ['query'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { query: string; calendarId?: string; maxResults?: number };
try {
const auth = createOAuth2Client(config);
const calendar = google.calendar({ version: 'v3', auth });
// For search, use a wide time window (1 year back to 1 year ahead)
const now = new Date();
const yearAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
const yearAhead = new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
const events = await fetchEvents(calendar, {
calendarId: args.calendarId,
timeMin: yearAgo.toISOString(),
timeMax: yearAhead.toISOString(),
q: args.query,
maxResults: args.maxResults,
});
return {
success: true,
output: formatEvents(events),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [calendarToday, calendarList, calendarSearch];
}
+259
View File
@@ -0,0 +1,259 @@
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(() => ({
mockMessagesList: vi.fn(),
mockMessagesGet: vi.fn(),
mockExistsSync: vi.fn(),
mockReadFileSync: vi.fn(),
}));
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: vi.fn().mockImplementation(() => ({
setCredentials: vi.fn(),
})),
},
gmail: vi.fn().mockReturnValue({
users: {
messages: {
list: mockMessagesList,
get: mockMessagesGet,
},
},
}),
},
}));
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
};
});
import { createGmailTools } from './gmail.js';
// ── Test config ─────────────────────────────────────────────────────────────
const testConfig: NonNullable<GmailConfig> = {
enabled: true,
credentials_file: '/tmp/test-creds.json',
token_file: '/tmp/test-token.json',
watch_labels: ['INBOX'],
poll_interval: '60s',
output: { channel: 'discord', peer: '123' },
message: '{{from}}: {{subject}}',
};
const fakeCredentials = {
installed: {
client_id: 'test-client-id',
client_secret: 'test-client-secret',
redirect_uris: ['http://localhost'],
},
};
const fakeToken = {
access_token: 'test-access-token',
refresh_token: 'test-refresh-token',
};
// ── Helpers ─────────────────────────────────────────────────────────────────
function setupValidAuth() {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockImplementation((path: unknown) => {
const p = String(path);
if (p.includes('creds')) return JSON.stringify(fakeCredentials);
if (p.includes('token')) return JSON.stringify(fakeToken);
return '';
});
}
function mockMessageDetails(id: string, from: string, subject: string, date: string, snippet: string) {
return {
data: {
payload: {
headers: [
{ name: 'From', value: from },
{ name: 'Subject', value: subject },
{ name: 'Date', value: date },
],
},
snippet,
},
};
}
// ═════════════════════════════════════════════════════════════════════════════
beforeEach(() => {
vi.clearAllMocks();
});
describe('createGmailTools', () => {
it('returns 2 tools with correct names', () => {
const tools = createGmailTools(testConfig);
expect(tools).toHaveLength(2);
expect(tools.map(t => t.name)).toEqual(['gmail.list', 'gmail.search']);
});
it('tools have descriptions and input schemas', () => {
const tools = createGmailTools(testConfig);
for (const tool of tools) {
expect(tool.description).toBeTruthy();
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe('object');
}
});
});
describe('gmail.list', () => {
it('returns error when credentials file missing', async () => {
mockExistsSync.mockReturnValue(false);
const [listTool] = createGmailTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('returns error when token file missing', async () => {
mockExistsSync.mockImplementation((path: unknown) => {
return String(path).includes('creds');
});
mockReadFileSync.mockReturnValue(JSON.stringify(fakeCredentials));
const [listTool] = createGmailTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(false);
expect(result.error).toContain('Token file not found');
});
it('lists recent emails with default params', async () => {
setupValidAuth();
mockMessagesList.mockResolvedValue({
data: {
messages: [{ id: 'msg1' }, { id: 'msg2' }],
},
});
mockMessagesGet
.mockResolvedValueOnce(mockMessageDetails('msg1', 'alice@test.com', 'Hello', 'Mon, 10 Feb 2026', 'Hi there'))
.mockResolvedValueOnce(mockMessageDetails('msg2', 'bob@test.com', 'Meeting', 'Mon, 10 Feb 2026', 'At 3pm'));
const [listTool] = createGmailTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toContain('alice@test.com');
expect(result.output).toContain('Hello');
expect(result.output).toContain('bob@test.com');
expect(result.output).toContain('Meeting');
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'me',
labelIds: ['INBOX'],
maxResults: 10,
}),
);
});
it('respects maxResults and label params', async () => {
setupValidAuth();
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
const [listTool] = createGmailTools(testConfig);
await listTool.execute({ maxResults: 5, label: 'SENT' });
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({
labelIds: ['SENT'],
maxResults: 5,
}),
);
});
it('handles empty results', async () => {
setupValidAuth();
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
const [listTool] = createGmailTools(testConfig);
const result = await listTool.execute({});
expect(result.success).toBe(true);
expect(result.output).toBe('No messages found.');
});
});
describe('gmail.search', () => {
it('searches with query parameter', async () => {
setupValidAuth();
mockMessagesList.mockResolvedValue({
data: {
messages: [{ id: 'msg1' }],
},
});
mockMessagesGet.mockResolvedValueOnce(
mockMessageDetails('msg1', 'alice@test.com', 'Invoice', 'Mon, 10 Feb 2026', 'Your invoice'),
);
const [, searchTool] = createGmailTools(testConfig);
const result = await searchTool.execute({ query: 'from:alice subject:invoice' });
expect(result.success).toBe(true);
expect(result.output).toContain('Invoice');
expect(result.output).toContain('alice@test.com');
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'me',
q: 'from:alice subject:invoice',
maxResults: 10,
}),
);
});
it('respects maxResults param', async () => {
setupValidAuth();
mockMessagesList.mockResolvedValue({ data: { messages: [] } });
const [, searchTool] = createGmailTools(testConfig);
await searchTool.execute({ query: 'is:unread', maxResults: 3 });
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({
q: 'is:unread',
maxResults: 3,
}),
);
});
it('returns error when credentials missing', async () => {
mockExistsSync.mockReturnValue(false);
const [, searchTool] = createGmailTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('Credentials file not found');
});
it('handles API errors gracefully', async () => {
setupValidAuth();
mockMessagesList.mockRejectedValue(new Error('API quota exceeded'));
const [, searchTool] = createGmailTools(testConfig);
const result = await searchTool.execute({ query: 'test' });
expect(result.success).toBe(false);
expect(result.error).toContain('API quota exceeded');
});
});
+216
View File
@@ -0,0 +1,216 @@
import { google, type Auth } from 'googleapis';
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { homedir } from 'os';
import type { GmailConfig } from '../../config/schema.js';
import type { Tool, ToolResult } from '../types.js';
/** Expand ~ to home directory. */
function expandPath(p: string): string {
if (p.startsWith('~/') || p === '~') {
return resolve(homedir(), p.slice(2));
}
return resolve(p);
}
/** Create an OAuth2 client from Gmail config (credentials + token files). */
function createOAuth2Client(config: NonNullable<GmailConfig>): Auth.OAuth2Client {
const credentialsPath = config.credentials_file;
if (!credentialsPath) {
throw new Error('No credentials_file configured. Set automation.gmail.credentials_file in config.');
}
const expandedCredsPath = expandPath(credentialsPath);
if (!existsSync(expandedCredsPath)) {
throw new Error(`Credentials file not found: ${expandedCredsPath}`);
}
const credentials = JSON.parse(readFileSync(expandedCredsPath, 'utf-8'));
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web ?? {};
if (!client_id || !client_secret) {
throw new Error('Invalid credentials file — missing client_id or client_secret');
}
const oauth2Client = new google.auth.OAuth2(
client_id,
client_secret,
redirect_uris?.[0] ?? 'http://localhost',
);
const tokenPath = expandPath(config.token_file ?? '~/.config/flynn/gmail-token.json');
if (!existsSync(tokenPath)) {
throw new Error(`Token file not found: ${tokenPath}. Run "flynn gmail-auth" to authenticate.`);
}
const token = JSON.parse(readFileSync(tokenPath, 'utf-8'));
oauth2Client.setCredentials(token);
return oauth2Client;
}
interface EmailSummary {
id: string;
from: string;
subject: string;
date: string;
snippet: string;
}
/** Fetch metadata for a single message. */
async function fetchMessageDetails(
gmail: ReturnType<typeof google.gmail>,
messageId: string,
): Promise<EmailSummary | null> {
try {
const msg = await gmail.users.messages.get({
userId: 'me',
id: messageId,
format: 'metadata',
metadataHeaders: ['From', 'Subject', 'Date'],
});
const headers = msg.data.payload?.headers ?? [];
const getHeader = (name: string): string =>
headers.find(h => h.name?.toLowerCase() === name.toLowerCase())?.value ?? '';
return {
id: messageId,
from: getHeader('From'),
subject: getHeader('Subject'),
date: getHeader('Date'),
snippet: msg.data.snippet ?? '',
};
} catch {
return null;
}
}
/** Format a list of email summaries for tool output. */
function formatEmails(emails: EmailSummary[]): string {
if (emails.length === 0) {
return 'No messages found.';
}
return emails
.map(e => `[${e.id}] ${e.date}\n From: ${e.from}\n Subject: ${e.subject}\n ${e.snippet}`)
.join('\n\n');
}
/**
* Creates Gmail query tools bound to the given GmailConfig.
* Tools create their own OAuth2 client, independent of GmailWatcher.
*/
export function createGmailTools(config: NonNullable<GmailConfig>): Tool[] {
const gmailList: Tool = {
name: 'gmail.list',
description:
'List recent emails from Gmail. Returns id, from, subject, date, and snippet for each message.',
inputSchema: {
type: 'object',
properties: {
maxResults: {
type: 'number',
description: 'Maximum number of emails to return (default: 10)',
},
label: {
type: 'string',
description: 'Gmail label to filter by (default: INBOX)',
},
},
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { maxResults?: number; label?: string };
const maxResults = args.maxResults ?? 10;
const label = args.label ?? 'INBOX';
try {
const auth = createOAuth2Client(config);
const gmail = google.gmail({ version: 'v1', auth });
const listResponse = await gmail.users.messages.list({
userId: 'me',
labelIds: [label],
maxResults,
});
const messages = listResponse.data.messages ?? [];
const emails: EmailSummary[] = [];
for (const msg of messages) {
if (!msg.id) continue;
const details = await fetchMessageDetails(gmail, msg.id);
if (details) emails.push(details);
}
return {
success: true,
output: formatEmails(emails),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
const gmailSearch: Tool = {
name: 'gmail.search',
description:
'Search Gmail using Gmail query syntax (e.g. "from:user@example.com", "is:unread", "subject:hello"). Returns id, from, subject, date, and snippet for each match.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Gmail search query (same syntax as Gmail search bar)',
},
maxResults: {
type: 'number',
description: 'Maximum number of results to return (default: 10)',
},
},
required: ['query'],
},
execute: async (rawArgs: unknown): Promise<ToolResult> => {
const args = rawArgs as { query: string; maxResults?: number };
const maxResults = args.maxResults ?? 10;
try {
const auth = createOAuth2Client(config);
const gmail = google.gmail({ version: 'v1', auth });
const listResponse = await gmail.users.messages.list({
userId: 'me',
q: args.query,
maxResults,
});
const messages = listResponse.data.messages ?? [];
const emails: EmailSummary[] = [];
for (const msg of messages) {
if (!msg.id) continue;
const details = await fetchMessageDetails(gmail, msg.id);
if (details) emails.push(details);
}
return {
success: true,
output: formatEmails(emails),
};
} catch (error) {
return {
success: false,
output: '',
error: error instanceof Error ? error.message : String(error),
};
}
},
};
return [gmailList, gmailSearch];
}
+2
View File
@@ -21,6 +21,8 @@ export { createSessionTools } from './sessions.js';
export { createAgentsListTool } from './agents-list.js';
export { createMessageSendTool } from './message-send.js';
export { createCronTools } from './cron.js';
export { createGmailTools } from './gmail.js';
export { createGcalTools } from './gcal.js';
import type { Tool } from '../types.js';
import type { MemoryStore } from '../../memory/store.js';