From 4c74b1134d3d918c8545c17dbdcc2dd21632744e Mon Sep 17 00:00:00 2001 From: Qasim Date: Mon, 26 Jan 2026 18:49:28 -0500 Subject: [PATCH] feat(nylas): add Nylas API v3 plugin for email, calendar, and contacts Add a new plugin extension that integrates with Nylas API v3 to provide email, calendar, and contacts functionality for Clawdbot agents. ## Features ### Tools (16 total) - Discovery: nylas_discover_grants (auto-discover authenticated accounts) - Email: list_emails, get_email, send_email, create_draft, list_threads, list_folders - Calendar: list_calendars, list_events, get_event, create_event, update_event, delete_event, check_availability - Contacts: list_contacts, get_contact ### CLI Commands - `clawdbot nylas status` - Check API connection and auto-discover grants - `clawdbot nylas discover` - List all authenticated accounts - `clawdbot nylas test` - Test API with sample operations - `clawdbot nylas grants` - Show configured grants ### Gateway Methods - 8 RPC methods for programmatic access to Nylas functionality ## Technical Details - Uses official Nylas Node SDK (nylas@^7.5.2) - TypeBox schemas for tool parameters - Zod for configuration validation - Multi-account support via named grants - Proper error handling with NylasApiError wrapper ## Configuration ```yaml plugins: entries: nylas: config: apiKey: "nyk_v0_..." defaultGrantId: "grant-id" grants: work: "work-grant-id" personal: "personal-grant-id" ``` ## Testing - 104 unit tests (100% coverage on core logic) - 19 integration tests against live Nylas API - Run with: `bun test extensions/nylas/` - Live tests: `LIVE=1 bun test extensions/nylas/index.live.test.ts` ## Documentation - README with setup instructions, tool reference, and troubleshooting - Support contacts: limitless@nylas.com, support@nylas.com --- extensions/nylas/README.md | 188 +++ extensions/nylas/clawdbot.plugin.json | 56 + extensions/nylas/index.live.test.ts | 477 ++++++ extensions/nylas/index.test.ts | 2037 ++++++++++++++++++++++++ extensions/nylas/index.ts | 213 +++ extensions/nylas/package.json | 16 + extensions/nylas/src/cli.ts | 362 +++++ extensions/nylas/src/client.ts | 534 +++++++ extensions/nylas/src/config.ts | 46 + extensions/nylas/src/tools/calendar.ts | 339 ++++ extensions/nylas/src/tools/contacts.ts | 101 ++ extensions/nylas/src/tools/email.ts | 273 ++++ extensions/nylas/src/tools/index.ts | 211 +++ 13 files changed, 4853 insertions(+) create mode 100644 extensions/nylas/README.md create mode 100644 extensions/nylas/clawdbot.plugin.json create mode 100644 extensions/nylas/index.live.test.ts create mode 100644 extensions/nylas/index.test.ts create mode 100644 extensions/nylas/index.ts create mode 100644 extensions/nylas/package.json create mode 100644 extensions/nylas/src/cli.ts create mode 100644 extensions/nylas/src/client.ts create mode 100644 extensions/nylas/src/config.ts create mode 100644 extensions/nylas/src/tools/calendar.ts create mode 100644 extensions/nylas/src/tools/contacts.ts create mode 100644 extensions/nylas/src/tools/email.ts create mode 100644 extensions/nylas/src/tools/index.ts diff --git a/extensions/nylas/README.md b/extensions/nylas/README.md new file mode 100644 index 000000000..f31a800cb --- /dev/null +++ b/extensions/nylas/README.md @@ -0,0 +1,188 @@ +# Nylas Plugin for Clawdbot + +Email, calendar, and contacts integration via Nylas API v3. + +Built on the [official Nylas Node SDK](https://www.npmjs.com/package/nylas). + +## Quick Start + +1. **Get API Key** - Sign up at https://dashboard.nylas.com and create an API key +2. **Add API key to config:** + ```yaml + plugins: + entries: + nylas: + config: + apiKey: "nyk_v0_your_key_here" + ``` +3. **Auto-discover grants:** + ```bash + clawdbot nylas status + ``` + This will connect to Nylas, discover all authenticated accounts, and show the recommended configuration. + +## Prerequisites + +Before using this plugin, you need to set up a Nylas account: + +1. **Create Nylas Account** - Sign up at https://dashboard.nylas.com +2. **Create Application** - All apps → Create new app → Choose region (US/EU) +3. **Get API Key** - API Keys section → Create new key +4. **Add Grants** - Grants section → Add Account → Authenticate email accounts +5. **Grant IDs are auto-discovered** - The plugin will automatically find your authenticated accounts + +## Configuration + +Add the following to your `clawdbot.yaml`: + +```yaml +plugins: + entries: + nylas: + enabled: true + config: + apiKey: "nyk_v0_..." # Required: API key from Nylas dashboard + apiUri: "https://api.us.nylas.com" # Optional: US or EU region + defaultTimezone: "America/New_York" # Optional: Default timezone for dates + defaultGrantId: "grant-id-here" # Primary account's grant ID + grants: # Optional: Named grants for multi-account + work: "grant-id-for-work-email" + personal: "grant-id-for-personal" +``` + +## CLI Commands + +```bash +# Check API connection and auto-discover grants (recommended first step) +clawdbot nylas status + +# Discover all authenticated accounts and show config snippet +clawdbot nylas discover +clawdbot nylas discover --json # Output as JSON + +# Test API with specific grant (auto-discovers if not configured) +clawdbot nylas test +clawdbot nylas test --grant work + +# List configured and available grants +clawdbot nylas grants +clawdbot nylas grants --configured # Only show configured grants +``` + +## Available Tools + +### Account Discovery + +| Tool | Description | +|------|-------------| +| `nylas_discover_grants` | Discover all authenticated email accounts (grants) available via this API key | + +### Email Tools + +| Tool | Description | +|------|-------------| +| `nylas_list_emails` | List/search emails with filters (folder, from, subject, date, unread, starred) | +| `nylas_get_email` | Get full email content by ID | +| `nylas_send_email` | Send email (to, cc, bcc, subject, body) | +| `nylas_create_draft` | Create email draft | +| `nylas_list_threads` | List email threads (conversations) | +| `nylas_list_folders` | List email folders (INBOX, SENT, etc.) | + +### Calendar Tools + +| Tool | Description | +|------|-------------| +| `nylas_list_calendars` | List available calendars | +| `nylas_list_events` | List/search events with date range | +| `nylas_get_event` | Get event details | +| `nylas_create_event` | Create event with attendees, location | +| `nylas_update_event` | Update existing event | +| `nylas_delete_event` | Delete event | +| `nylas_check_availability` | Check availability for participants | + +### Contact Tools + +| Tool | Description | +|------|-------------| +| `nylas_list_contacts` | List/search contacts | +| `nylas_get_contact` | Get contact details | + +## Multi-Account Support + +Use the optional `grant` parameter on any tool to specify which account to use: + +``` +// Use the default grant +nylas_list_emails({ folder: "INBOX" }) + +// Use a named grant +nylas_list_emails({ grant: "work", folder: "INBOX" }) + +// Use a raw grant ID +nylas_list_emails({ grant: "abc123-grant-id", folder: "INBOX" }) +``` + +## Example Usage + +**List recent unread emails:** +``` +nylas_list_emails({ unread: true, limit: 10 }) +``` + +**Send an email:** +``` +nylas_send_email({ + to: "recipient@example.com", + subject: "Hello", + body: "

This is the email body.

" +}) +``` + +**Create a calendar event:** +``` +nylas_create_event({ + calendar_id: "primary", + title: "Team Meeting", + start: "2024-03-20T14:00:00-05:00", + end: "2024-03-20T15:00:00-05:00", + participants: "alice@example.com, bob@example.com", + location: "Conference Room A" +}) +``` + +**Check availability:** +``` +nylas_check_availability({ + emails: "alice@example.com, bob@example.com", + start: "2024-03-20T09:00:00-05:00", + end: "2024-03-20T17:00:00-05:00", + duration_minutes: 30 +}) +``` + +## Troubleshooting + +**"apiKey is required"** +- Add your Nylas API key to the plugin configuration + +**"No grant ID provided"** +- Set `defaultGrantId` or pass `grant` parameter to tools + +**401 Unauthorized** +- Check that your API key is valid and not expired +- Verify the grant ID exists in your Nylas dashboard + +**404 Not Found** +- Ensure the resource (message, event, contact) ID is correct +- Check that you're using the right grant for that resource + +## Support + +For Nylas API issues, contact: +- limitless@nylas.com +- support@nylas.com + +## Nylas Documentation + +- [Nylas API v3 Docs](https://developer.nylas.com/docs/v3/) +- [Nylas Dashboard](https://dashboard.nylas.com) diff --git a/extensions/nylas/clawdbot.plugin.json b/extensions/nylas/clawdbot.plugin.json new file mode 100644 index 000000000..d78ef17bb --- /dev/null +++ b/extensions/nylas/clawdbot.plugin.json @@ -0,0 +1,56 @@ +{ + "id": "nylas", + "uiHints": { + "apiKey": { + "label": "API Key", + "help": "Nylas API key from dashboard.nylas.com", + "sensitive": true + }, + "apiUri": { + "label": "API URI", + "help": "Use https://api.us.nylas.com (US) or https://api.eu.nylas.com (EU)", + "placeholder": "https://api.us.nylas.com" + }, + "defaultGrantId": { + "label": "Default Grant ID", + "help": "Primary email account's grant ID from Nylas dashboard" + }, + "defaultTimezone": { + "label": "Default Timezone", + "help": "Timezone for date/time operations (e.g., America/New_York)", + "placeholder": "America/New_York" + }, + "grants": { + "label": "Named Grants", + "help": "Map of named grants for multi-account access (e.g., work: grant-id-1)", + "advanced": true + } + }, + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "apiKey": { + "type": "string" + }, + "apiUri": { + "type": "string" + }, + "defaultGrantId": { + "type": "string" + }, + "defaultTimezone": { + "type": "string" + }, + "grants": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } +} diff --git a/extensions/nylas/index.live.test.ts b/extensions/nylas/index.live.test.ts new file mode 100644 index 000000000..7d47d699b --- /dev/null +++ b/extensions/nylas/index.live.test.ts @@ -0,0 +1,477 @@ +/** + * Nylas Plugin Integration Tests + * + * These tests run against the real Nylas API. + * Set NYLAS_API_KEY env variable and run with: + * LIVE=1 bun test extensions/nylas/index.live.test.ts + * + * Or run all live tests: + * pnpm test:live + */ + +import { describe, expect, it, beforeAll } from "vitest"; +import { parseNylasConfig, type NylasConfig } from "./src/config.js"; +import { NylasClient, NylasApiError } from "./src/client.js"; +import { discoverGrants } from "./src/tools/index.js"; +import { listEmails, getEmail, listFolders, listThreads } from "./src/tools/email.js"; +import { listCalendars, listEvents } from "./src/tools/calendar.js"; +import { listContacts } from "./src/tools/contacts.js"; + +// Check environment +const NYLAS_API_KEY = process.env.NYLAS_API_KEY ?? ""; +const LIVE = process.env.LIVE === "1" || process.env.NYLAS_LIVE_TEST === "1"; + +const describeLive = LIVE && NYLAS_API_KEY ? describe : describe.skip; + +// Store discovered grant for subsequent tests +let testGrantId: string | undefined; +let testCalendarId: string | undefined; + +describeLive("Nylas Plugin Integration Tests", () => { + let client: NylasClient; + let config: NylasConfig; + + beforeAll(() => { + config = parseNylasConfig({ + apiKey: NYLAS_API_KEY, + apiUri: "https://api.us.nylas.com", + defaultTimezone: "UTC", + }); + + client = new NylasClient({ + config, + logger: { + info: () => {}, + warn: console.warn, + error: console.error, + }, + }); + }); + + // =========================================================================== + // Grant Discovery Tests + // =========================================================================== + describe("Grant Discovery", () => { + it("lists grants from the API", async () => { + const response = await client.listGrants(); + + expect(response).toBeDefined(); + expect(response.data).toBeInstanceOf(Array); + + if (response.data.length > 0) { + const grant = response.data[0]; + expect(grant.id).toBeDefined(); + expect(grant.email).toBeDefined(); + expect(grant.provider).toBeDefined(); + + // Store for subsequent tests + testGrantId = grant.id; + console.log(` Found grant: ${grant.email} (${grant.provider})`); + } else { + console.log(" No grants found - some tests will be skipped"); + } + }, 15000); + + it("discovers grants via tool", async () => { + const result = await discoverGrants(client, {}); + + expect(result).toBeDefined(); + expect(result.grants).toBeInstanceOf(Array); + expect(result.count).toBe(result.grants.length); + + if (result.grants.length > 0) { + expect(result.hint).toContain("grant"); + expect(result.grants[0].id).toBeDefined(); + expect(result.grants[0].email).toBeDefined(); + } + }, 15000); + + it("filters grants by provider", async () => { + const result = await discoverGrants(client, { provider: "google" }); + + expect(result).toBeDefined(); + expect(result.grants).toBeInstanceOf(Array); + + // All returned grants should be Google + for (const grant of result.grants) { + expect(grant.provider).toBe("google"); + } + }, 15000); + + it("gets specific grant details", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + const response = await client.getGrant(testGrantId); + + expect(response.data).toBeDefined(); + expect(response.data.id).toBe(testGrantId); + expect(response.data.email).toBeDefined(); + expect(response.data.grantStatus).toBeDefined(); + }, 15000); + }); + + // =========================================================================== + // Email Tools Tests + // =========================================================================== + describe("Email Tools", () => { + it("lists email folders", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + const result = await listFolders(client, { grant: testGrantId }); + + expect(result).toBeDefined(); + expect(result.folders).toBeInstanceOf(Array); + + if (result.folders.length > 0) { + const folder = result.folders[0]; + expect(folder.id).toBeDefined(); + expect(folder.name).toBeDefined(); + console.log(` Found ${result.folders.length} folders`); + } + }, 15000); + + it("lists emails with limit", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + const result = await listEmails(client, { + grant: testGrantId, + limit: 5, + }); + + expect(result).toBeDefined(); + expect(result.emails).toBeInstanceOf(Array); + expect(result.emails.length).toBeLessThanOrEqual(5); + + if (result.emails.length > 0) { + const email = result.emails[0]; + expect(email.id).toBeDefined(); + expect(email.subject).toBeDefined(); + console.log(` Found ${result.emails.length} emails`); + } + }, 15000); + + it("lists unread emails", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + const result = await listEmails(client, { + grant: testGrantId, + unread: true, + limit: 10, + }); + + expect(result).toBeDefined(); + expect(result.emails).toBeInstanceOf(Array); + + // All returned emails should be unread + for (const email of result.emails) { + expect(email.unread).toBe(true); + } + console.log(` Found ${result.emails.length} unread emails`); + }, 15000); + + it("gets a specific email", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + // First get an email ID + const listResult = await listEmails(client, { + grant: testGrantId, + limit: 1, + }); + + if (listResult.emails.length === 0) { + console.log(" Skipping - no emails available"); + return; + } + + const emailId = listResult.emails[0].id; + const result = await getEmail(client, { + grant: testGrantId, + message_id: emailId, + }); + + expect(result).toBeDefined(); + expect(result.id).toBe(emailId); + expect(result.subject).toBeDefined(); + expect(result.body).toBeDefined(); + console.log(` Got email: ${result.subject?.slice(0, 50)}...`); + }, 15000); + + it("lists email threads", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + const result = await listThreads(client, { + grant: testGrantId, + limit: 5, + }); + + expect(result).toBeDefined(); + expect(result.threads).toBeInstanceOf(Array); + + if (result.threads.length > 0) { + const thread = result.threads[0]; + expect(thread.id).toBeDefined(); + expect(thread.subject).toBeDefined(); + console.log(` Found ${result.threads.length} threads`); + } + }, 15000); + }); + + // =========================================================================== + // Calendar Tools Tests + // =========================================================================== + describe("Calendar Tools", () => { + it("lists calendars", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + const result = await listCalendars(client, { grant: testGrantId }); + + expect(result).toBeDefined(); + expect(result.calendars).toBeInstanceOf(Array); + + if (result.calendars.length > 0) { + const calendar = result.calendars[0]; + expect(calendar.id).toBeDefined(); + expect(calendar.name).toBeDefined(); + + // Find primary calendar for subsequent tests + const primary = result.calendars.find((c) => c.is_primary); + testCalendarId = primary?.id ?? result.calendars[0].id; + console.log(` Found ${result.calendars.length} calendars`); + } + }, 15000); + + it("lists events with date range", async () => { + if (!testGrantId || !testCalendarId) { + console.log(" Skipping - no grant or calendar available"); + return; + } + + // Get events from the last 30 days + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const result = await listEvents(client, { + grant: testGrantId, + calendar_id: testCalendarId, + start: thirtyDaysAgo.toISOString(), + end: now.toISOString(), + limit: 10, + }); + + expect(result).toBeDefined(); + expect(result.events).toBeInstanceOf(Array); + console.log(` Found ${result.events.length} events in last 30 days`); + + if (result.events.length > 0) { + const event = result.events[0]; + expect(event.id).toBeDefined(); + expect(event.title).toBeDefined(); + } + }, 15000); + + it("lists upcoming events", async () => { + if (!testGrantId || !testCalendarId) { + console.log(" Skipping - no grant or calendar available"); + return; + } + + // Get events for the next 7 days + const now = new Date(); + const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + + const result = await listEvents(client, { + grant: testGrantId, + calendar_id: testCalendarId, + start: now.toISOString(), + end: sevenDaysLater.toISOString(), + limit: 10, + }); + + expect(result).toBeDefined(); + expect(result.events).toBeInstanceOf(Array); + console.log(` Found ${result.events.length} upcoming events`); + }, 15000); + }); + + // =========================================================================== + // Contact Tools Tests + // =========================================================================== + describe("Contact Tools", () => { + it("lists contacts", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + const result = await listContacts(client, { + grant: testGrantId, + limit: 10, + }); + + expect(result).toBeDefined(); + expect(result.contacts).toBeInstanceOf(Array); + console.log(` Found ${result.contacts.length} contacts`); + + if (result.contacts.length > 0) { + const contact = result.contacts[0]; + expect(contact.id).toBeDefined(); + } + }, 15000); + + it("lists contacts with email filter", async () => { + if (!testGrantId) { + console.log(" Skipping - no grant available"); + return; + } + + // First get a contact to filter by + const allContacts = await listContacts(client, { + grant: testGrantId, + limit: 1, + }); + + if (allContacts.contacts.length === 0 || !allContacts.contacts[0].emails?.[0]) { + console.log(" Skipping - no contacts with email available"); + return; + } + + const emailToFind = allContacts.contacts[0].emails[0].email; + const result = await listContacts(client, { + grant: testGrantId, + email: emailToFind, + }); + + expect(result).toBeDefined(); + expect(result.contacts).toBeInstanceOf(Array); + console.log(` Found ${result.contacts.length} contacts matching ${emailToFind}`); + }, 15000); + }); + + // =========================================================================== + // Error Handling Tests + // =========================================================================== + describe("Error Handling", () => { + it("handles invalid grant ID gracefully", async () => { + try { + await client.getGrant("invalid-grant-id-12345"); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).toBeInstanceOf(NylasApiError); + const apiError = err as NylasApiError; + expect(apiError.statusCode).toBe(404); + } + }, 15000); + + it("handles invalid API key gracefully", async () => { + const badConfig = parseNylasConfig({ + apiKey: "invalid-api-key", + apiUri: "https://api.us.nylas.com", + }); + + const badClient = new NylasClient({ + config: badConfig, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }); + + try { + await badClient.listGrants(); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).toBeInstanceOf(NylasApiError); + const apiError = err as NylasApiError; + expect(apiError.statusCode).toBe(401); + } + }, 15000); + }); + + // =========================================================================== + // Client Configuration Tests + // =========================================================================== + describe("Client Configuration", () => { + it("resolves named grants correctly", () => { + const configWithGrants = parseNylasConfig({ + apiKey: NYLAS_API_KEY, + defaultGrantId: "default-grant-123", + grants: { + work: "work-grant-456", + personal: "personal-grant-789", + }, + }); + + const clientWithGrants = new NylasClient({ + config: configWithGrants, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }); + + // Test resolving named grants + expect(clientWithGrants.resolveGrantId("work")).toBe("work-grant-456"); + expect(clientWithGrants.resolveGrantId("personal")).toBe("personal-grant-789"); + + // Test default grant + expect(clientWithGrants.resolveGrantId()).toBe("default-grant-123"); + + // Test raw grant ID passthrough + expect(clientWithGrants.resolveGrantId("some-raw-id")).toBe("some-raw-id"); + }); + + it("throws when no grant ID available", () => { + const configNoDefault = parseNylasConfig({ + apiKey: NYLAS_API_KEY, + }); + + const clientNoDefault = new NylasClient({ + config: configNoDefault, + logger: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + }); + + expect(() => clientNoDefault.resolveGrantId()).toThrow("No grant ID provided"); + }); + }); +}); + +// =========================================================================== +// Summary when tests complete +// =========================================================================== +describeLive("Integration Test Summary", () => { + it("displays test environment info", () => { + console.log("\n Nylas Integration Tests Summary:"); + console.log(` API URI: https://api.us.nylas.com`); + console.log(` API Key: ${NYLAS_API_KEY.slice(0, 10)}...`); + console.log(` Test Grant ID: ${testGrantId ?? "(none found)"}`); + console.log(` Test Calendar ID: ${testCalendarId ?? "(none found)"}`); + expect(true).toBe(true); + }); +}); diff --git a/extensions/nylas/index.test.ts b/extensions/nylas/index.test.ts new file mode 100644 index 000000000..868bf4e1d --- /dev/null +++ b/extensions/nylas/index.test.ts @@ -0,0 +1,2037 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { parseNylasConfig, validateNylasConfig, type NylasConfig } from "./src/config.js"; +import { NylasClient, NylasApiError } from "./src/client.js"; +import { nylasTools, discoverGrants } from "./src/tools/index.js"; +import { registerNylasCli } from "./src/cli.js"; +import { + listEmails, + getEmail, + sendEmail, + createDraft, + listThreads, + listFolders, +} from "./src/tools/email.js"; +import { + listCalendars, + listEvents, + getEvent, + createEvent, + updateEvent, + deleteEvent, + checkAvailability, +} from "./src/tools/calendar.js"; +import { listContacts, getContact } from "./src/tools/contacts.js"; +import nylasPlugin from "./index.js"; + +// Mock the Nylas SDK +vi.mock("nylas", () => { + return { + default: vi.fn().mockImplementation(() => ({ + grants: { + list: vi.fn(), + find: vi.fn(), + }, + messages: { + list: vi.fn(), + find: vi.fn(), + send: vi.fn(), + }, + threads: { + list: vi.fn(), + }, + folders: { + list: vi.fn(), + }, + drafts: { + create: vi.fn(), + }, + calendars: { + list: vi.fn(), + getAvailability: vi.fn(), + }, + events: { + list: vi.fn(), + find: vi.fn(), + create: vi.fn(), + update: vi.fn(), + destroy: vi.fn(), + }, + contacts: { + list: vi.fn(), + find: vi.fn(), + }, + })), + }; +}); + +describe("Nylas Plugin", () => { + // =========================================================================== + // Config Tests + // =========================================================================== + describe("Config", () => { + describe("parseNylasConfig", () => { + it("parses minimal config with defaults", () => { + const config = parseNylasConfig({}); + expect(config.enabled).toBe(true); + expect(config.apiUri).toBe("https://api.us.nylas.com"); + expect(config.defaultTimezone).toBe("UTC"); + expect(config.grants).toEqual({}); + expect(config.apiKey).toBeUndefined(); + expect(config.defaultGrantId).toBeUndefined(); + }); + + it("parses full config", () => { + const config = parseNylasConfig({ + enabled: false, + apiKey: "test-key", + apiUri: "https://api.eu.nylas.com", + defaultGrantId: "grant-123", + defaultTimezone: "America/New_York", + grants: { work: "work-grant", personal: "personal-grant" }, + }); + expect(config.enabled).toBe(false); + expect(config.apiKey).toBe("test-key"); + expect(config.apiUri).toBe("https://api.eu.nylas.com"); + expect(config.defaultGrantId).toBe("grant-123"); + expect(config.defaultTimezone).toBe("America/New_York"); + expect(config.grants).toEqual({ work: "work-grant", personal: "personal-grant" }); + }); + + it("handles non-object input", () => { + const config = parseNylasConfig(null); + expect(config.enabled).toBe(true); + expect(config.apiUri).toBe("https://api.us.nylas.com"); + }); + + it("handles array input", () => { + const config = parseNylasConfig([]); + expect(config.enabled).toBe(true); + }); + + it("handles string input", () => { + const config = parseNylasConfig("invalid"); + expect(config.enabled).toBe(true); + }); + + it("preserves boolean enabled=false", () => { + const config = parseNylasConfig({ enabled: false }); + expect(config.enabled).toBe(false); + }); + + it("uses EU API URI when specified", () => { + const config = parseNylasConfig({ apiUri: "https://api.eu.nylas.com" }); + expect(config.apiUri).toBe("https://api.eu.nylas.com"); + }); + }); + + describe("validateNylasConfig", () => { + it("validates missing apiKey", () => { + const config = parseNylasConfig({ defaultGrantId: "grant-123" }); + const validation = validateNylasConfig(config); + expect(validation.valid).toBe(false); + if (!validation.valid) { + expect(validation.errors).toContain("apiKey is required"); + } + }); + + it("validates missing grant", () => { + const config = parseNylasConfig({ apiKey: "test-key" }); + const validation = validateNylasConfig(config); + expect(validation.valid).toBe(false); + if (!validation.valid) { + expect(validation.errors).toContain("defaultGrantId or at least one named grant is required"); + } + }); + + it("validates with defaultGrantId", () => { + const config = parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-123" }); + const validation = validateNylasConfig(config); + expect(validation.valid).toBe(true); + }); + + it("validates with named grants only", () => { + const config = parseNylasConfig({ apiKey: "test-key", grants: { work: "grant-123" } }); + const validation = validateNylasConfig(config); + expect(validation.valid).toBe(true); + }); + + it("returns multiple errors", () => { + const config = parseNylasConfig({}); + const validation = validateNylasConfig(config); + expect(validation.valid).toBe(false); + if (!validation.valid) { + expect(validation.errors.length).toBe(2); + expect(validation.errors).toContain("apiKey is required"); + expect(validation.errors).toContain("defaultGrantId or at least one named grant is required"); + } + }); + }); + }); + + // =========================================================================== + // Client Tests + // =========================================================================== + describe("Client", () => { + const testConfig: NylasConfig = { + enabled: true, + apiKey: "test-api-key", + apiUri: "https://api.us.nylas.com", + defaultGrantId: "default-grant", + defaultTimezone: "UTC", + grants: { work: "work-grant", personal: "personal-grant" }, + }; + + it("creates client with config", () => { + const client = new NylasClient({ config: testConfig }); + expect(client).toBeInstanceOf(NylasClient); + expect(client.sdk).toBeDefined(); + }); + + describe("resolveGrantId", () => { + it("returns default grant when no argument", () => { + const client = new NylasClient({ config: testConfig }); + expect(client.resolveGrantId()).toBe("default-grant"); + }); + + it("resolves named grant", () => { + const client = new NylasClient({ config: testConfig }); + expect(client.resolveGrantId("work")).toBe("work-grant"); + expect(client.resolveGrantId("personal")).toBe("personal-grant"); + }); + + it("returns raw grant ID if not a named grant", () => { + const client = new NylasClient({ config: testConfig }); + expect(client.resolveGrantId("some-random-id")).toBe("some-random-id"); + }); + + it("throws when no default grant and no argument", () => { + const config = { ...testConfig, defaultGrantId: undefined }; + const client = new NylasClient({ config }); + expect(() => client.resolveGrantId()).toThrow("No grant ID provided"); + }); + }); + + describe("listGrants", () => { + it("calls SDK grants.list", async () => { + const client = new NylasClient({ config: testConfig }); + const mockData = { + data: [ + { id: "grant-1", email: "test@example.com", provider: "google", grantStatus: "valid" }, + ], + }; + (client.sdk.grants.list as any).mockResolvedValue(mockData); + + const result = await client.listGrants(); + expect(client.sdk.grants.list).toHaveBeenCalled(); + expect(result.data).toHaveLength(1); + expect(result.data[0].email).toBe("test@example.com"); + }); + + it("passes query params", async () => { + const client = new NylasClient({ config: testConfig }); + (client.sdk.grants.list as any).mockResolvedValue({ data: [] }); + + await client.listGrants({ limit: 10, provider: "google" }); + expect(client.sdk.grants.list).toHaveBeenCalledWith({ + queryParams: { limit: 10, provider: "google", offset: undefined, email: undefined }, + }); + }); + }); + + describe("listMessages", () => { + it("calls SDK messages.list with resolved grant", async () => { + const client = new NylasClient({ config: testConfig }); + const mockData = { data: [], nextCursor: null }; + (client.sdk.messages.list as any).mockResolvedValue(mockData); + + await client.listMessages({ grant: "work", limit: 10 }); + expect(client.sdk.messages.list).toHaveBeenCalledWith({ + identifier: "work-grant", + queryParams: expect.objectContaining({ limit: 10 }), + }); + }); + }); + + describe("error handling", () => { + it("wraps SDK errors", async () => { + const client = new NylasClient({ config: testConfig }); + const sdkError = { message: "Unauthorized", statusCode: 401, requestId: "req-123" }; + (client.sdk.grants.list as any).mockRejectedValue(sdkError); + + await expect(client.listGrants()).rejects.toThrow(NylasApiError); + try { + await client.listGrants(); + } catch (err) { + expect(err).toBeInstanceOf(NylasApiError); + expect((err as NylasApiError).statusCode).toBe(401); + expect((err as NylasApiError).requestId).toBe("req-123"); + } + }); + + it("handles generic errors", async () => { + const client = new NylasClient({ config: testConfig }); + (client.sdk.grants.list as any).mockRejectedValue(new Error("Network error")); + + await expect(client.listGrants()).rejects.toThrow(NylasApiError); + }); + }); + }); + + // =========================================================================== + // NylasApiError Tests + // =========================================================================== + describe("NylasApiError", () => { + it("creates error with all properties", () => { + const error = new NylasApiError("Test error", 401, "req-123", "unauthorized"); + expect(error.message).toBe("Test error"); + expect(error.statusCode).toBe(401); + expect(error.requestId).toBe("req-123"); + expect(error.errorType).toBe("unauthorized"); + expect(error.name).toBe("NylasApiError"); + }); + + it("creates error with minimal properties", () => { + const error = new NylasApiError("Test error"); + expect(error.message).toBe("Test error"); + expect(error.statusCode).toBeUndefined(); + expect(error.requestId).toBeUndefined(); + }); + + it("is instanceof Error", () => { + const error = new NylasApiError("Test"); + expect(error).toBeInstanceOf(Error); + }); + }); + + // =========================================================================== + // Tools Tests + // =========================================================================== + describe("Tools", () => { + it("has correct number of tools", () => { + expect(nylasTools.length).toBe(16); + }); + + it("all tools have required properties", () => { + for (const tool of nylasTools) { + expect(tool.name).toBeDefined(); + expect(tool.name.startsWith("nylas_")).toBe(true); + expect(tool.label).toBeDefined(); + expect(tool.description).toBeDefined(); + expect(tool.parameters).toBeDefined(); + expect(typeof tool.execute).toBe("function"); + } + }); + + it("all tool names are unique", () => { + const names = nylasTools.map((t) => t.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(names.length); + }); + + describe("discover grants tool", () => { + it("exists and has correct metadata", () => { + const tool = nylasTools.find((t) => t.name === "nylas_discover_grants"); + expect(tool).toBeDefined(); + expect(tool?.description).toContain("authenticated"); + expect(tool?.label).toBe("Discover Grants"); + }); + }); + + describe("email tools", () => { + const emailTools = ["nylas_list_emails", "nylas_get_email", "nylas_send_email", "nylas_create_draft", "nylas_list_threads", "nylas_list_folders"]; + + it("includes all email tools", () => { + for (const name of emailTools) { + const tool = nylasTools.find((t) => t.name === name); + expect(tool).toBeDefined(); + } + }); + + it("list_emails has correct parameters", () => { + const tool = nylasTools.find((t) => t.name === "nylas_list_emails"); + expect(tool?.parameters).toBeDefined(); + const props = (tool?.parameters as any).properties; + expect(props.grant).toBeDefined(); + expect(props.folder).toBeDefined(); + expect(props.subject).toBeDefined(); + expect(props.from).toBeDefined(); + expect(props.unread).toBeDefined(); + expect(props.limit).toBeDefined(); + }); + + it("send_email has required parameters", () => { + const tool = nylasTools.find((t) => t.name === "nylas_send_email"); + const required = (tool?.parameters as any).required ?? []; + expect(required).toContain("to"); + expect(required).toContain("subject"); + expect(required).toContain("body"); + }); + }); + + describe("calendar tools", () => { + const calendarTools = ["nylas_list_calendars", "nylas_list_events", "nylas_get_event", "nylas_create_event", "nylas_update_event", "nylas_delete_event", "nylas_check_availability"]; + + it("includes all calendar tools", () => { + for (const name of calendarTools) { + const tool = nylasTools.find((t) => t.name === name); + expect(tool).toBeDefined(); + } + }); + + it("create_event has required parameters", () => { + const tool = nylasTools.find((t) => t.name === "nylas_create_event"); + const required = (tool?.parameters as any).required ?? []; + expect(required).toContain("calendar_id"); + expect(required).toContain("title"); + expect(required).toContain("start"); + }); + + it("check_availability has correct parameters", () => { + const tool = nylasTools.find((t) => t.name === "nylas_check_availability"); + const props = (tool?.parameters as any).properties; + expect(props.emails).toBeDefined(); + expect(props.start).toBeDefined(); + expect(props.end).toBeDefined(); + expect(props.duration_minutes).toBeDefined(); + }); + }); + + describe("contact tools", () => { + it("includes list_contacts tool", () => { + const tool = nylasTools.find((t) => t.name === "nylas_list_contacts"); + expect(tool).toBeDefined(); + expect(tool?.description).toContain("contact"); + }); + + it("includes get_contact tool", () => { + const tool = nylasTools.find((t) => t.name === "nylas_get_contact"); + expect(tool).toBeDefined(); + const required = (tool?.parameters as any).required ?? []; + expect(required).toContain("contact_id"); + }); + }); + }); + + // =========================================================================== + // Plugin Definition Tests + // =========================================================================== + describe("Plugin Definition", () => { + it("has correct metadata", () => { + expect(nylasPlugin.id).toBe("nylas"); + expect(nylasPlugin.name).toBe("Nylas"); + expect(nylasPlugin.description.toLowerCase()).toContain("email"); + expect(nylasPlugin.description.toLowerCase()).toContain("calendar"); + }); + + it("has config schema with parse function", () => { + expect(nylasPlugin.configSchema).toBeDefined(); + expect(typeof nylasPlugin.configSchema.parse).toBe("function"); + }); + + it("has config schema with uiHints", () => { + expect(nylasPlugin.configSchema.uiHints).toBeDefined(); + expect(nylasPlugin.configSchema.uiHints.apiKey).toBeDefined(); + expect(nylasPlugin.configSchema.uiHints.apiKey.sensitive).toBe(true); + }); + + it("has register function", () => { + expect(typeof nylasPlugin.register).toBe("function"); + }); + + describe("register", () => { + it("registers all tools", () => { + const registeredTools: string[] = []; + const mockApi = { + id: "nylas", + name: "Nylas", + source: "test", + config: {}, + pluginConfig: { apiKey: "test", defaultGrantId: "grant" }, + runtime: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerTool: (tool: any) => registeredTools.push(tool.name), + registerCli: vi.fn(), + registerGatewayMethod: vi.fn(), + }; + + nylasPlugin.register(mockApi as any); + expect(registeredTools).toHaveLength(16); + expect(registeredTools).toContain("nylas_discover_grants"); + expect(registeredTools).toContain("nylas_list_emails"); + expect(registeredTools).toContain("nylas_send_email"); + }); + + it("registers CLI commands", () => { + const mockRegisterCli = vi.fn(); + const mockApi = { + id: "nylas", + name: "Nylas", + source: "test", + config: {}, + pluginConfig: { apiKey: "test", defaultGrantId: "grant" }, + runtime: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerTool: vi.fn(), + registerCli: mockRegisterCli, + registerGatewayMethod: vi.fn(), + }; + + nylasPlugin.register(mockApi as any); + expect(mockRegisterCli).toHaveBeenCalledWith( + expect.any(Function), + { commands: ["nylas"] }, + ); + }); + + it("registers gateway methods", () => { + const registeredMethods: string[] = []; + const mockApi = { + id: "nylas", + name: "Nylas", + source: "test", + config: {}, + pluginConfig: { apiKey: "test", defaultGrantId: "grant" }, + runtime: {}, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + registerTool: vi.fn(), + registerCli: vi.fn(), + registerGatewayMethod: (method: string) => registeredMethods.push(method), + }; + + nylasPlugin.register(mockApi as any); + expect(registeredMethods).toContain("nylas.discoverGrants"); + expect(registeredMethods).toContain("nylas.listEmails"); + expect(registeredMethods).toContain("nylas.getMessage"); + expect(registeredMethods).toContain("nylas.sendMessage"); + expect(registeredMethods).toContain("nylas.listCalendars"); + expect(registeredMethods).toContain("nylas.listEvents"); + expect(registeredMethods).toContain("nylas.createEvent"); + expect(registeredMethods).toContain("nylas.listContacts"); + }); + }); + }); +}); + +// =========================================================================== +// Email Tools Implementation Tests +// =========================================================================== +describe("Email Tools", () => { + const createMockClient = () => { + return { + listMessages: vi.fn(), + getMessage: vi.fn(), + sendMessage: vi.fn(), + createDraft: vi.fn(), + listThreads: vi.fn(), + listFolders: vi.fn(), + } as any; + }; + + describe("listEmails", () => { + it("returns formatted email list", async () => { + const client = createMockClient(); + client.listMessages.mockResolvedValue({ + data: [ + { + id: "msg-1", + threadId: "thread-1", + subject: "Test Subject", + from: [{ email: "sender@example.com", name: "Sender" }], + to: [{ email: "recipient@example.com" }], + date: 1700000000, + unread: true, + starred: false, + snippet: "Preview text...", + attachments: [], + folders: ["INBOX"], + }, + ], + nextCursor: "next-token", + }); + + const result = await listEmails(client, { limit: 10 }); + + expect(result.emails).toHaveLength(1); + expect(result.emails[0].id).toBe("msg-1"); + expect(result.emails[0].subject).toBe("Test Subject"); + expect(result.emails[0].from).toBe("Sender "); + expect(result.emails[0].unread).toBe(true); + expect(result.has_more).toBe(true); + expect(result.next_page_token).toBe("next-token"); + }); + + it("limits results to max 50", async () => { + const client = createMockClient(); + client.listMessages.mockResolvedValue({ data: [], nextCursor: null }); + + await listEmails(client, { limit: 100 }); + + expect(client.listMessages).toHaveBeenCalledWith( + expect.objectContaining({ limit: 50 }), + ); + }); + + it("converts date filters to timestamps", async () => { + const client = createMockClient(); + client.listMessages.mockResolvedValue({ data: [], nextCursor: null }); + + await listEmails(client, { + received_after: "2024-01-01T00:00:00Z", + received_before: "2024-12-31T23:59:59Z", + }); + + expect(client.listMessages).toHaveBeenCalledWith( + expect.objectContaining({ + receivedAfter: expect.any(Number), + receivedBefore: expect.any(Number), + }), + ); + }); + }); + + describe("getEmail", () => { + it("returns formatted email with full body", async () => { + const client = createMockClient(); + client.getMessage.mockResolvedValue({ + data: { + id: "msg-1", + threadId: "thread-1", + subject: "Test", + from: [{ email: "sender@example.com" }], + to: [{ email: "recipient@example.com" }], + cc: [{ email: "cc@example.com" }], + bcc: [], + replyTo: [], + date: 1700000000, + body: "

Email body

", + unread: false, + starred: true, + folders: ["INBOX"], + attachments: [ + { id: "att-1", filename: "file.pdf", contentType: "application/pdf", size: 1024 }, + ], + }, + }); + + const result = await getEmail(client, { message_id: "msg-1" }); + + expect(result.id).toBe("msg-1"); + expect(result.body).toBe("

Email body

"); + expect(result.starred).toBe(true); + expect(result.attachments).toHaveLength(1); + expect(result.attachments![0].filename).toBe("file.pdf"); + }); + }); + + describe("sendEmail", () => { + it("sends email and returns message id", async () => { + const client = createMockClient(); + client.sendMessage.mockResolvedValue({ + data: { id: "sent-msg-1", threadId: "thread-1" }, + }); + + const result = await sendEmail(client, { + to: "recipient@example.com", + subject: "Test", + body: "Hello", + }); + + expect(result.success).toBe(true); + expect(result.message_id).toBe("sent-msg-1"); + expect(client.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + to: [{ email: "recipient@example.com" }], + subject: "Test", + body: "Hello", + }), + ); + }); + + it("parses multiple recipients", async () => { + const client = createMockClient(); + client.sendMessage.mockResolvedValue({ + data: { id: "msg-1", threadId: "thread-1" }, + }); + + await sendEmail(client, { + to: "a@example.com, John ", + cc: "c@example.com", + subject: "Test", + body: "Hello", + }); + + expect(client.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + to: [ + { email: "a@example.com" }, + { email: "b@example.com", name: "John" }, + ], + cc: [{ email: "c@example.com" }], + }), + ); + }); + }); + + describe("createDraft", () => { + it("creates draft and returns draft id", async () => { + const client = createMockClient(); + client.createDraft.mockResolvedValue({ + data: { id: "draft-1", threadId: "thread-1" }, + }); + + const result = await createDraft(client, { + to: "recipient@example.com", + subject: "Draft subject", + body: "Draft body", + }); + + expect(result.success).toBe(true); + expect(result.draft_id).toBe("draft-1"); + }); + }); + + describe("listThreads", () => { + it("returns formatted thread list", async () => { + const client = createMockClient(); + client.listThreads.mockResolvedValue({ + data: [ + { + id: "thread-1", + subject: "Thread Subject", + participants: [ + { email: "a@example.com", name: "Alice" }, + { email: "b@example.com" }, + ], + messageIds: ["msg-1", "msg-2", "msg-3"], + snippet: "Latest message...", + unread: true, + starred: false, + latestMessageReceivedDate: 1700000000, + folders: ["INBOX"], + }, + ], + nextCursor: null, + }); + + const result = await listThreads(client, {}); + + expect(result.threads).toHaveLength(1); + expect(result.threads[0].subject).toBe("Thread Subject"); + expect(result.threads[0].message_count).toBe(3); + expect(result.threads[0].participants).toContain("Alice "); + expect(result.has_more).toBe(false); + }); + }); + + describe("listFolders", () => { + it("returns formatted folder list", async () => { + const client = createMockClient(); + client.listFolders.mockResolvedValue({ + data: [ + { id: "folder-1", name: "INBOX", systemFolder: true, unreadCount: 5, totalCount: 100 }, + { id: "folder-2", name: "Custom", systemFolder: false, totalCount: 10 }, + ], + }); + + const result = await listFolders(client, {}); + + expect(result.folders).toHaveLength(2); + expect(result.folders[0].name).toBe("INBOX"); + expect(result.folders[0].system_folder).toBe(true); + expect(result.folders[0].unread_count).toBe(5); + }); + }); +}); + +// =========================================================================== +// Calendar Tools Implementation Tests +// =========================================================================== +describe("Calendar Tools", () => { + const createMockClient = () => { + return { + listCalendars: vi.fn(), + listEvents: vi.fn(), + getEvent: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + checkAvailability: vi.fn(), + } as any; + }; + + describe("listCalendars", () => { + it("returns formatted calendar list", async () => { + const client = createMockClient(); + client.listCalendars.mockResolvedValue({ + data: [ + { id: "cal-1", name: "Primary", isPrimary: true, timezone: "America/New_York", readOnly: false }, + { id: "cal-2", name: "Work", isPrimary: false, description: "Work calendar" }, + ], + }); + + const result = await listCalendars(client, {}); + + expect(result.calendars).toHaveLength(2); + expect(result.calendars[0].name).toBe("Primary"); + expect(result.calendars[0].is_primary).toBe(true); + }); + }); + + describe("listEvents", () => { + it("returns formatted event list with timespan", async () => { + const client = createMockClient(); + client.listEvents.mockResolvedValue({ + data: [ + { + id: "event-1", + calendarId: "cal-1", + title: "Meeting", + description: "Team sync", + location: "Room A", + when: { startTime: 1700000000, endTime: 1700003600 }, + participants: [{ email: "attendee@example.com", status: "yes" }], + status: "confirmed", + busy: true, + conferencing: { details: { url: "https://meet.example.com/123" } }, + }, + ], + nextCursor: null, + }); + + const result = await listEvents(client, {}); + + expect(result.events).toHaveLength(1); + expect(result.events[0].title).toBe("Meeting"); + expect(result.events[0].start).toBeDefined(); + expect(result.events[0].end).toBeDefined(); + expect(result.events[0].conferencing_url).toBe("https://meet.example.com/123"); + }); + + it("handles all-day events", async () => { + const client = createMockClient(); + client.listEvents.mockResolvedValue({ + data: [ + { + id: "event-1", + calendarId: "cal-1", + title: "Holiday", + when: { date: "2024-12-25" }, + }, + ], + nextCursor: null, + }); + + const result = await listEvents(client, {}); + + expect(result.events[0].all_day).toBe(true); + expect(result.events[0].start).toBe("2024-12-25"); + }); + }); + + describe("createEvent", () => { + it("creates timespan event", async () => { + const client = createMockClient(); + client.createEvent.mockResolvedValue({ + data: { + id: "new-event", + calendarId: "cal-1", + title: "New Meeting", + when: { startTime: 1700000000, endTime: 1700003600 }, + }, + }); + + const result = await createEvent(client, { + calendar_id: "cal-1", + title: "New Meeting", + start: "2024-03-20T10:00:00Z", + end: "2024-03-20T11:00:00Z", + }); + + expect(result.success).toBe(true); + expect(result.event_id).toBe("new-event"); + expect(client.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: "cal-1", + title: "New Meeting", + when: expect.objectContaining({ + startTime: expect.any(Number), + endTime: expect.any(Number), + }), + }), + ); + }); + + it("creates all-day event", async () => { + const client = createMockClient(); + client.createEvent.mockResolvedValue({ + data: { + id: "new-event", + calendarId: "cal-1", + title: "Holiday", + when: { date: "2024-12-25" }, + }, + }); + + const result = await createEvent(client, { + calendar_id: "cal-1", + title: "Holiday", + start: "2024-12-25", + all_day: true, + }); + + expect(result.success).toBe(true); + expect(client.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + when: { date: "2024-12-25" }, + }), + ); + }); + + it("parses participants", async () => { + const client = createMockClient(); + client.createEvent.mockResolvedValue({ + data: { id: "event-1", calendarId: "cal-1", when: {} }, + }); + + await createEvent(client, { + calendar_id: "cal-1", + title: "Meeting", + start: "2024-03-20T10:00:00Z", + participants: "alice@example.com, bob@example.com", + }); + + expect(client.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + participants: [ + { email: "alice@example.com" }, + { email: "bob@example.com" }, + ], + }), + ); + }); + }); + + describe("updateEvent", () => { + it("updates event fields", async () => { + const client = createMockClient(); + client.updateEvent.mockResolvedValue({ + data: { id: "event-1", title: "Updated Title", when: {} }, + }); + + const result = await updateEvent(client, { + event_id: "event-1", + calendar_id: "cal-1", + title: "Updated Title", + location: "New Room", + }); + + expect(result.success).toBe(true); + expect(client.updateEvent).toHaveBeenCalledWith( + "event-1", + "cal-1", + expect.objectContaining({ + title: "Updated Title", + location: "New Room", + }), + ); + }); + }); + + describe("deleteEvent", () => { + it("deletes event", async () => { + const client = createMockClient(); + client.deleteEvent.mockResolvedValue(undefined); + + const result = await deleteEvent(client, { + event_id: "event-1", + calendar_id: "cal-1", + }); + + expect(result.success).toBe(true); + expect(result.event_id).toBe("event-1"); + expect(client.deleteEvent).toHaveBeenCalledWith("event-1", "cal-1", undefined); + }); + }); + + describe("checkAvailability", () => { + it("returns available time slots", async () => { + const client = createMockClient(); + client.checkAvailability.mockResolvedValue({ + data: { + timeSlots: [ + { startTime: 1700000000, endTime: 1700001800, emails: ["alice@example.com"] }, + { startTime: 1700003600, endTime: 1700005400, emails: ["alice@example.com", "bob@example.com"] }, + ], + order: ["alice@example.com", "bob@example.com"], + }, + }); + + const result = await checkAvailability(client, { + emails: "alice@example.com, bob@example.com", + start: "2024-03-20T09:00:00Z", + end: "2024-03-20T17:00:00Z", + duration_minutes: 30, + }); + + expect(result.available_slots).toHaveLength(2); + expect(result.slot_count).toBe(2); + expect(result.checked_emails).toContain("alice@example.com"); + }); + }); +}); + +// =========================================================================== +// Contact Tools Implementation Tests +// =========================================================================== +describe("Contact Tools", () => { + const createMockClient = () => { + return { + listContacts: vi.fn(), + getContact: vi.fn(), + } as any; + }; + + describe("listContacts", () => { + it("returns formatted contact list", async () => { + const client = createMockClient(); + client.listContacts.mockResolvedValue({ + data: [ + { + id: "contact-1", + givenName: "John", + surname: "Doe", + companyName: "Acme Inc", + jobTitle: "Engineer", + emails: [{ email: "john@example.com", type: "work" }], + phoneNumbers: [{ number: "+1234567890", type: "mobile" }], + source: "address_book", + }, + ], + nextCursor: "next-token", + }); + + const result = await listContacts(client, {}); + + expect(result.contacts).toHaveLength(1); + expect(result.contacts[0].name).toBe("John Doe"); + expect(result.contacts[0].company).toBe("Acme Inc"); + expect(result.contacts[0].emails).toHaveLength(1); + expect(result.has_more).toBe(true); + }); + + it("handles contact with nickname only", async () => { + const client = createMockClient(); + client.listContacts.mockResolvedValue({ + data: [ + { id: "contact-1", nickname: "Johnny" }, + ], + nextCursor: null, + }); + + const result = await listContacts(client, {}); + + expect(result.contacts[0].name).toBe("Johnny"); + }); + + it("handles contact with no name", async () => { + const client = createMockClient(); + client.listContacts.mockResolvedValue({ + data: [ + { id: "contact-1", emails: [{ email: "unknown@example.com" }] }, + ], + nextCursor: null, + }); + + const result = await listContacts(client, {}); + + expect(result.contacts[0].name).toBe("(no name)"); + }); + }); + + describe("getContact", () => { + it("returns full contact details", async () => { + const client = createMockClient(); + client.getContact.mockResolvedValue({ + data: { + id: "contact-1", + givenName: "Jane", + middleName: "Marie", + surname: "Smith", + nickname: "Janie", + companyName: "Tech Corp", + jobTitle: "Manager", + emails: [ + { email: "jane.work@example.com", type: "work" }, + { email: "jane.personal@example.com", type: "home" }, + ], + phoneNumbers: [{ number: "+1234567890", type: "work" }], + physicalAddresses: [ + { + streetAddress: "123 Main St", + city: "New York", + state: "NY", + postalCode: "10001", + country: "USA", + type: "work", + }, + ], + notes: "Important contact", + birthday: "1990-01-15", + pictureUrl: "https://example.com/photo.jpg", + source: "address_book", + groups: [{ id: "group-1" }], + }, + }); + + const result = await getContact(client, { contact_id: "contact-1" }); + + expect(result.name).toBe("Jane Marie Smith"); + expect(result.given_name).toBe("Jane"); + expect(result.middle_name).toBe("Marie"); + expect(result.surname).toBe("Smith"); + expect(result.emails).toHaveLength(2); + expect(result.addresses).toHaveLength(1); + expect(result.addresses![0].city).toBe("New York"); + expect(result.notes).toBe("Important contact"); + expect(result.birthday).toBe("1990-01-15"); + }); + }); +}); + +// =========================================================================== +// Discover Grants Tool Tests +// =========================================================================== +describe("Discover Grants Tool", () => { + it("returns formatted grants list", async () => { + const mockClient = { + listGrants: vi.fn().mockResolvedValue({ + data: [ + { id: "grant-1", email: "user@gmail.com", provider: "google", grantStatus: "valid", scope: ["email", "calendar"] }, + { id: "grant-2", email: "user@outlook.com", provider: "microsoft", grantStatus: "valid" }, + ], + }), + }; + + const result = await discoverGrants(mockClient as any, {}); + + expect(result.grants).toHaveLength(2); + expect(result.grants[0].email).toBe("user@gmail.com"); + expect(result.grants[0].provider).toBe("google"); + expect(result.grants[0].status).toBe("valid"); + expect(result.count).toBe(2); + expect(result.hint).toContain("grant"); + }); + + it("returns hint when no grants found", async () => { + const mockClient = { + listGrants: vi.fn().mockResolvedValue({ data: [] }), + }; + + const result = await discoverGrants(mockClient as any, {}); + + expect(result.grants).toHaveLength(0); + expect(result.count).toBe(0); + expect(result.hint).toContain("dashboard.nylas.com"); + }); + + it("filters by provider", async () => { + const mockClient = { + listGrants: vi.fn().mockResolvedValue({ data: [] }), + }; + + await discoverGrants(mockClient as any, { provider: "google" }); + + expect(mockClient.listGrants).toHaveBeenCalledWith({ provider: "google", email: undefined }); + }); +}); + +// =========================================================================== +// Additional Client Method Tests +// =========================================================================== +describe("Client Methods", () => { + const createMockSdk = () => ({ + grants: { list: vi.fn(), find: vi.fn() }, + messages: { list: vi.fn(), find: vi.fn(), send: vi.fn() }, + threads: { list: vi.fn() }, + folders: { list: vi.fn() }, + drafts: { create: vi.fn() }, + calendars: { list: vi.fn(), getAvailability: vi.fn() }, + events: { list: vi.fn(), find: vi.fn(), create: vi.fn(), update: vi.fn(), destroy: vi.fn() }, + contacts: { list: vi.fn(), find: vi.fn() }, + }); + + describe("getMessage", () => { + it("fetches a specific message by ID", async () => { + const mockSdk = createMockSdk(); + mockSdk.messages.find.mockResolvedValue({ + data: { + id: "msg-123", + subject: "Test Email", + body: "

Hello

", + }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.getMessage("msg-123"); + + expect(mockSdk.messages.find).toHaveBeenCalledWith({ + identifier: "grant-1", + messageId: "msg-123", + }); + expect(result.data.id).toBe("msg-123"); + }); + }); + + describe("sendMessage", () => { + it("sends an email with all fields", async () => { + const mockSdk = createMockSdk(); + mockSdk.messages.send.mockResolvedValue({ + data: { id: "sent-msg-123" }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.sendMessage({ + to: [{ email: "to@example.com", name: "To" }], + cc: [{ email: "cc@example.com" }], + bcc: [{ email: "bcc@example.com" }], + subject: "Test Subject", + body: "

Test body

", + replyToMessageId: "reply-to-123", + }); + + expect(mockSdk.messages.send).toHaveBeenCalledWith({ + identifier: "grant-1", + requestBody: { + to: [{ email: "to@example.com", name: "To" }], + cc: [{ email: "cc@example.com" }], + bcc: [{ email: "bcc@example.com" }], + replyTo: undefined, + subject: "Test Subject", + body: "

Test body

", + replyToMessageId: "reply-to-123", + }, + }); + expect(result.data.id).toBe("sent-msg-123"); + }); + }); + + describe("createDraft", () => { + it("creates a draft email", async () => { + const mockSdk = createMockSdk(); + mockSdk.drafts.create.mockResolvedValue({ + data: { id: "draft-123" }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.createDraft({ + to: [{ email: "to@example.com" }], + subject: "Draft Subject", + body: "

Draft body

", + }); + + expect(mockSdk.drafts.create).toHaveBeenCalledWith({ + identifier: "grant-1", + requestBody: { + to: [{ email: "to@example.com" }], + cc: undefined, + bcc: undefined, + subject: "Draft Subject", + body: "

Draft body

", + replyToMessageId: undefined, + }, + }); + expect(result.data.id).toBe("draft-123"); + }); + }); + + describe("listCalendars", () => { + it("lists calendars for a grant", async () => { + const mockSdk = createMockSdk(); + mockSdk.calendars.list.mockResolvedValue({ + data: [ + { id: "cal-1", name: "Primary", isPrimary: true }, + { id: "cal-2", name: "Work" }, + ], + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.listCalendars(); + + expect(mockSdk.calendars.list).toHaveBeenCalledWith({ identifier: "grant-1" }); + expect(result.data).toHaveLength(2); + }); + }); + + describe("getEvent", () => { + it("fetches a specific event by ID", async () => { + const mockSdk = createMockSdk(); + mockSdk.events.find.mockResolvedValue({ + data: { + id: "event-123", + title: "Meeting", + when: { startTime: 1700000000, endTime: 1700003600 }, + }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.getEvent("event-123", "cal-1"); + + expect(mockSdk.events.find).toHaveBeenCalledWith({ + identifier: "grant-1", + eventId: "event-123", + queryParams: { calendarId: "cal-1" }, + }); + expect(result.data.title).toBe("Meeting"); + }); + }); + + describe("createEvent", () => { + it("creates a new event", async () => { + const mockSdk = createMockSdk(); + mockSdk.events.create.mockResolvedValue({ + data: { id: "new-event-123" }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.createEvent({ + calendarId: "cal-1", + title: "New Meeting", + description: "A test meeting", + location: "Room A", + when: { startTime: 1700000000, endTime: 1700003600 }, + participants: [{ email: "attendee@example.com" }], + busy: true, + }); + + expect(mockSdk.events.create).toHaveBeenCalledWith({ + identifier: "grant-1", + queryParams: { calendarId: "cal-1" }, + requestBody: { + title: "New Meeting", + description: "A test meeting", + location: "Room A", + when: { startTime: 1700000000, endTime: 1700003600 }, + participants: [{ email: "attendee@example.com" }], + busy: true, + }, + }); + expect(result.data.id).toBe("new-event-123"); + }); + }); + + describe("updateEvent", () => { + it("updates an existing event", async () => { + const mockSdk = createMockSdk(); + mockSdk.events.update.mockResolvedValue({ + data: { id: "event-123", title: "Updated Meeting" }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.updateEvent("event-123", "cal-1", { + title: "Updated Meeting", + location: "Room B", + }); + + expect(mockSdk.events.update).toHaveBeenCalledWith({ + identifier: "grant-1", + eventId: "event-123", + queryParams: { calendarId: "cal-1" }, + requestBody: { + title: "Updated Meeting", + description: undefined, + location: "Room B", + when: undefined, + participants: undefined, + busy: undefined, + }, + }); + expect(result.data.title).toBe("Updated Meeting"); + }); + }); + + describe("deleteEvent", () => { + it("deletes an event", async () => { + const mockSdk = createMockSdk(); + mockSdk.events.destroy.mockResolvedValue(undefined); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await client.deleteEvent("event-123", "cal-1"); + + expect(mockSdk.events.destroy).toHaveBeenCalledWith({ + identifier: "grant-1", + eventId: "event-123", + queryParams: { calendarId: "cal-1" }, + }); + }); + }); + + describe("checkAvailability", () => { + it("checks availability for participants", async () => { + const mockSdk = createMockSdk(); + mockSdk.calendars.getAvailability.mockResolvedValue({ + data: { + timeSlots: [ + { startTime: 1700000000, endTime: 1700001800 }, + ], + }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.checkAvailability({ + startTime: 1700000000, + endTime: 1700010000, + participants: [{ email: "person@example.com" }], + durationMinutes: 30, + intervalMinutes: 15, + }); + + expect(mockSdk.calendars.getAvailability).toHaveBeenCalledWith({ + requestBody: { + startTime: 1700000000, + endTime: 1700010000, + participants: [{ email: "person@example.com" }], + durationMinutes: 30, + intervalMinutes: 15, + }, + }); + expect(result.data.timeSlots).toHaveLength(1); + }); + }); + + describe("getContact", () => { + it("fetches a specific contact by ID", async () => { + const mockSdk = createMockSdk(); + mockSdk.contacts.find.mockResolvedValue({ + data: { + id: "contact-123", + givenName: "John", + surname: "Doe", + }, + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.getContact("contact-123"); + + expect(mockSdk.contacts.find).toHaveBeenCalledWith({ + identifier: "grant-1", + contactId: "contact-123", + }); + expect(result.data.givenName).toBe("John"); + }); + }); + + describe("listFolders", () => { + it("lists email folders", async () => { + const mockSdk = createMockSdk(); + mockSdk.folders.list.mockResolvedValue({ + data: [ + { id: "folder-1", name: "INBOX" }, + { id: "folder-2", name: "SENT" }, + ], + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.listFolders(); + + expect(mockSdk.folders.list).toHaveBeenCalledWith({ identifier: "grant-1" }); + expect(result.data).toHaveLength(2); + }); + }); + + describe("listThreads", () => { + it("lists email threads with filters", async () => { + const mockSdk = createMockSdk(); + mockSdk.threads.list.mockResolvedValue({ + data: [ + { id: "thread-1", subject: "Thread 1" }, + { id: "thread-2", subject: "Thread 2" }, + ], + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.listThreads({ limit: 10, unread: true }); + + expect(mockSdk.threads.list).toHaveBeenCalledWith({ + identifier: "grant-1", + queryParams: { limit: 10, unread: true }, + }); + expect(result.data).toHaveLength(2); + }); + + it("lists threads without params", async () => { + const mockSdk = createMockSdk(); + mockSdk.threads.list.mockResolvedValue({ data: [] }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await client.listThreads(); + + expect(mockSdk.threads.list).toHaveBeenCalledWith({ + identifier: "grant-1", + queryParams: undefined, + }); + }); + }); + + describe("listEvents", () => { + it("lists events with calendar and date range", async () => { + const mockSdk = createMockSdk(); + mockSdk.events.list.mockResolvedValue({ + data: [ + { id: "event-1", title: "Meeting" }, + ], + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.listEvents({ + calendarId: "cal-1", + start: 1700000000, + end: 1700010000, + limit: 10, + }); + + expect(mockSdk.events.list).toHaveBeenCalledWith({ + identifier: "grant-1", + queryParams: { calendarId: "cal-1", start: 1700000000, end: 1700010000, limit: 10 }, + }); + expect(result.data).toHaveLength(1); + }); + + it("uses primary calendar by default", async () => { + const mockSdk = createMockSdk(); + mockSdk.events.list.mockResolvedValue({ data: [] }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await client.listEvents(); + + expect(mockSdk.events.list).toHaveBeenCalledWith({ + identifier: "grant-1", + queryParams: { calendarId: "primary" }, + }); + }); + }); + + describe("listContacts", () => { + it("lists contacts with filters", async () => { + const mockSdk = createMockSdk(); + mockSdk.contacts.list.mockResolvedValue({ + data: [ + { id: "contact-1", givenName: "John" }, + ], + }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + const result = await client.listContacts({ limit: 10, email: "john@example.com" }); + + expect(mockSdk.contacts.list).toHaveBeenCalledWith({ + identifier: "grant-1", + queryParams: { limit: 10, email: "john@example.com" }, + }); + expect(result.data).toHaveLength(1); + }); + + it("lists contacts without params", async () => { + const mockSdk = createMockSdk(); + mockSdk.contacts.list.mockResolvedValue({ data: [] }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await client.listContacts(); + + expect(mockSdk.contacts.list).toHaveBeenCalledWith({ + identifier: "grant-1", + queryParams: undefined, + }); + }); + }); + + describe("error handling in methods", () => { + it("wraps errors from listThreads", async () => { + const mockSdk = createMockSdk(); + mockSdk.threads.list.mockRejectedValue(new Error("Network error")); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await expect(client.listThreads()).rejects.toThrow(NylasApiError); + }); + + it("wraps errors from listEvents", async () => { + const mockSdk = createMockSdk(); + mockSdk.events.list.mockRejectedValue({ statusCode: 404, message: "Not found" }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await expect(client.listEvents()).rejects.toThrow(NylasApiError); + }); + + it("wraps errors from listContacts", async () => { + const mockSdk = createMockSdk(); + mockSdk.contacts.list.mockRejectedValue({ statusCode: 400, message: "Bad request" }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await expect(client.listContacts()).rejects.toThrow(NylasApiError); + }); + + it("wraps errors from listGrants", async () => { + const mockSdk = createMockSdk(); + mockSdk.grants.list.mockRejectedValue({ statusCode: 401, message: "Unauthorized" }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await expect(client.listGrants()).rejects.toThrow(NylasApiError); + }); + + it("wraps errors from getGrant", async () => { + const mockSdk = createMockSdk(); + mockSdk.grants.find.mockRejectedValue({ statusCode: 404, message: "Not found" }); + + const client = new NylasClient({ + config: parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }), + }); + (client as any).sdk = mockSdk; + + await expect(client.getGrant("invalid-id")).rejects.toThrow(NylasApiError); + }); + }); +}); + +// =========================================================================== +// Calendar Tools - Additional Tests +// =========================================================================== +describe("Calendar Tools - Additional", () => { + describe("listCalendars", () => { + it("handles calendars without isPrimary field", async () => { + const mockClient = { + listCalendars: vi.fn().mockResolvedValue({ + data: [ + { id: "cal-1", name: "Calendar 1" }, + { id: "cal-2", name: "Calendar 2", isPrimary: true }, + ], + }), + }; + + const result = await listCalendars(mockClient as any, {}); + + expect(result.calendars).toHaveLength(2); + expect(result.calendars[0].is_primary).toBeUndefined(); + expect(result.calendars[1].is_primary).toBe(true); + }); + }); + + describe("listEvents with edge cases", () => { + it("handles events with different when types", async () => { + const mockClient = { + listEvents: vi.fn().mockResolvedValue({ + data: [ + // Date range (all-day multi-day event) + { id: "event-1", title: "Vacation", when: { startDate: "2024-03-20", endDate: "2024-03-25" } }, + // Single date (all-day) + { id: "event-2", title: "Holiday", when: { date: "2024-03-20" } }, + // Single time (point-in-time) + { id: "event-3", title: "Reminder", when: { time: 1700000000 } }, + // Time range (normal event) + { id: "event-4", title: "Meeting", when: { startTime: 1700000000, endTime: 1700003600 } }, + // Unknown format + { id: "event-5", title: "Unknown", when: { custom: "value" } }, + // Missing when + { id: "event-6", title: "No Time" }, + ], + nextCursor: null, + }), + }; + + const result = await listEvents(mockClient as any, { calendar_id: "cal-1" }); + + expect(result.events).toHaveLength(6); + // Date range (all-day) + expect(result.events[0].start).toBe("2024-03-20"); + expect(result.events[0].end).toBe("2024-03-25"); + expect(result.events[0].all_day).toBe(true); + // Single date (all-day) + expect(result.events[1].start).toBe("2024-03-20"); + expect(result.events[1].all_day).toBe(true); + // Single time + expect(result.events[2].start).toContain("2023-11"); + // Time range + expect(result.events[3].start).toContain("2023-11"); + expect(result.events[3].end).toContain("2023-11"); + // Unknown format - should stringify + expect(result.events[4].start).toContain("custom"); + // Missing when + expect(result.events[5].start).toBe("unknown"); + }); + }); + + describe("checkAvailability", () => { + it("checks availability for multiple participants", async () => { + const mockClient = { + checkAvailability: vi.fn().mockResolvedValue({ + data: { + timeSlots: [ + { startTime: 1700000000, endTime: 1700001800 }, + { startTime: 1700003600, endTime: 1700005400 }, + ], + order: ["alice@example.com", "bob@example.com"], + }, + }), + }; + + const result = await checkAvailability(mockClient as any, { + emails: "alice@example.com, bob@example.com", + start: "2024-03-20T09:00:00Z", + end: "2024-03-20T17:00:00Z", + duration_minutes: 30, + }); + + expect(result.available_slots).toHaveLength(2); + expect(result.slot_count).toBe(2); + expect(result.checked_emails).toContain("alice@example.com"); + expect(result.checked_emails).toContain("bob@example.com"); + expect(mockClient.checkAvailability).toHaveBeenCalledWith({ + startTime: expect.any(Number), + endTime: expect.any(Number), + participants: [{ email: "alice@example.com" }, { email: "bob@example.com" }], + durationMinutes: 30, + intervalMinutes: 15, + }); + }); + }); + + describe("getEvent", () => { + it("fetches and formats event details", async () => { + const mockClient = { + getEvent: vi.fn().mockResolvedValue({ + data: { + id: "event-123", + calendarId: "cal-1", + title: "Team Standup", + description: "Daily standup meeting", + location: "Zoom", + when: { startTime: 1700000000, endTime: 1700001800 }, + participants: [ + { email: "alice@example.com", name: "Alice", status: "yes" }, + { email: "bob@example.com", status: "noreply" }, + ], + busy: true, + status: "confirmed", + htmlLink: "https://calendar.google.com/event/123", + }, + }), + }; + + const result = await getEvent(mockClient as any, { + event_id: "event-123", + calendar_id: "cal-1", + }); + + expect(result.id).toBe("event-123"); + expect(result.title).toBe("Team Standup"); + expect(result.description).toBe("Daily standup meeting"); + expect(result.location).toBe("Zoom"); + expect(result.participants).toContain("Alice alice@example.com (yes)"); + expect(result.participants).toContain("bob@example.com"); + expect(result.busy).toBe(true); + }); + }); + + describe("createEvent", () => { + it("creates event with participants", async () => { + const mockClient = { + createEvent: vi.fn().mockResolvedValue({ + data: { id: "new-event-123", calendarId: "cal-1", title: "New Meeting", when: { startTime: 1700000000, endTime: 1700003600 } }, + }), + }; + + const result = await createEvent(mockClient as any, { + calendar_id: "cal-1", + title: "New Meeting", + start: "2024-03-20T14:00:00Z", + end: "2024-03-20T15:00:00Z", + participants: "alice@example.com, bob@example.com", + location: "Room A", + description: "Important meeting", + }); + + expect(result.event_id).toBe("new-event-123"); + expect(result.success).toBe(true); + expect(mockClient.createEvent).toHaveBeenCalledWith(expect.objectContaining({ + calendarId: "cal-1", + title: "New Meeting", + location: "Room A", + description: "Important meeting", + })); + }); + + it("creates all-day event", async () => { + const mockClient = { + createEvent: vi.fn().mockResolvedValue({ + data: { id: "allday-123", calendarId: "cal-1", title: "All Day Event", when: { date: "2024-03-20" } }, + }), + }; + + const result = await createEvent(mockClient as any, { + calendar_id: "cal-1", + title: "All Day Event", + start: "2024-03-20", + all_day: true, + }); + + expect(result.event_id).toBe("allday-123"); + expect(result.success).toBe(true); + expect(mockClient.createEvent).toHaveBeenCalled(); + }); + }); + + describe("updateEvent", () => { + it("updates event fields", async () => { + const mockClient = { + updateEvent: vi.fn().mockResolvedValue({ + data: { id: "event-123", title: "Updated Title", when: { startTime: 1700000000, endTime: 1700003600 } }, + }), + }; + + const result = await updateEvent(mockClient as any, { + event_id: "event-123", + calendar_id: "cal-1", + title: "Updated Title", + location: "New Room", + }); + + expect(result.event_id).toBe("event-123"); + expect(result.success).toBe(true); + expect(mockClient.updateEvent).toHaveBeenCalledWith("event-123", "cal-1", expect.objectContaining({ + title: "Updated Title", + location: "New Room", + })); + }); + }); + + describe("deleteEvent", () => { + it("deletes an event", async () => { + const mockClient = { + deleteEvent: vi.fn().mockResolvedValue(undefined), + }; + + const result = await deleteEvent(mockClient as any, { + event_id: "event-123", + calendar_id: "cal-1", + }); + + expect(result.success).toBe(true); + expect(result.event_id).toBe("event-123"); + expect(mockClient.deleteEvent).toHaveBeenCalledWith("event-123", "cal-1", undefined); + }); + }); +}); + +// =========================================================================== +// Email Tools - Additional Tests +// =========================================================================== +describe("Email Tools - Additional", () => { + describe("sendEmail", () => { + it("sends email with CC and BCC", async () => { + const mockClient = { + sendMessage: vi.fn().mockResolvedValue({ + data: { + id: "sent-123", + threadId: "thread-456", + }, + }), + }; + + const result = await sendEmail(mockClient as any, { + to: "recipient@example.com", + cc: "cc@example.com", + bcc: "bcc@example.com", + subject: "Test Subject", + body: "

Test body

", + }); + + expect(result.message_id).toBe("sent-123"); + expect(result.success).toBe(true); + expect(result.thread_id).toBe("thread-456"); + expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + to: [{ email: "recipient@example.com" }], + cc: [{ email: "cc@example.com" }], + bcc: [{ email: "bcc@example.com" }], + subject: "Test Subject", + body: "

Test body

", + })); + }); + + it("parses named email addresses", async () => { + const mockClient = { + sendMessage: vi.fn().mockResolvedValue({ + data: { id: "sent-123" }, + }), + }; + + await sendEmail(mockClient as any, { + to: "John Doe , jane@example.com", + subject: "Test", + body: "Body", + }); + + expect(mockClient.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ + to: [ + { name: "John Doe", email: "john@example.com" }, + { email: "jane@example.com" }, + ], + })); + }); + }); + + describe("createDraft", () => { + it("creates a draft email", async () => { + const mockClient = { + createDraft: vi.fn().mockResolvedValue({ + data: { id: "draft-123", threadId: "thread-789" }, + }), + }; + + const result = await createDraft(mockClient as any, { + to: "recipient@example.com", + subject: "Draft Subject", + body: "

Draft body

", + }); + + expect(result.draft_id).toBe("draft-123"); + expect(result.success).toBe(true); + expect(result.thread_id).toBe("thread-789"); + }); + }); +}); + +// =========================================================================== +// CLI Tests +// =========================================================================== +describe("CLI Commands", () => { + it("registers all CLI commands", () => { + const registeredCommands: string[] = []; + const registeredOptions: string[] = []; + + const mockSubCommand = { + description: vi.fn().mockReturnThis(), + option: vi.fn().mockImplementation((opt) => { + registeredOptions.push(opt); + return mockSubCommand; + }), + action: vi.fn().mockReturnThis(), + }; + + const mockNylasCommand = { + description: vi.fn().mockReturnThis(), + command: vi.fn().mockImplementation((name) => { + registeredCommands.push(name); + return mockSubCommand; + }), + }; + + const mockProgram = { + command: vi.fn().mockReturnValue(mockNylasCommand), + }; + + const config = parseNylasConfig({ apiKey: "test-key", defaultGrantId: "grant-1" }); + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + registerNylasCli({ program: mockProgram as any, config, logger }); + + // Main nylas command should be registered + expect(mockProgram.command).toHaveBeenCalledWith("nylas"); + expect(mockNylasCommand.description).toHaveBeenCalledWith(expect.stringContaining("Nylas")); + + // Sub-commands should be registered + expect(registeredCommands).toContain("status"); + expect(registeredCommands).toContain("discover"); + expect(registeredCommands).toContain("test"); + expect(registeredCommands).toContain("grants"); + + // Options should be registered + expect(registeredOptions.some((o) => o.includes("--json"))).toBe(true); + expect(registeredOptions.some((o) => o.includes("--grant"))).toBe(true); + expect(registeredOptions.some((o) => o.includes("--configured"))).toBe(true); + }); + + it("registerNylasCli is exported and callable", () => { + expect(typeof registerNylasCli).toBe("function"); + }); +}); diff --git a/extensions/nylas/index.ts b/extensions/nylas/index.ts new file mode 100644 index 000000000..d9953b33a --- /dev/null +++ b/extensions/nylas/index.ts @@ -0,0 +1,213 @@ +import { NylasConfigSchema, parseNylasConfig, validateNylasConfig, type NylasConfig } from "./src/config.js"; +import { NylasClient, NylasApiError } from "./src/client.js"; +import { nylasTools } from "./src/tools/index.js"; +import { registerNylasCli } from "./src/cli.js"; + +const nylasConfigSchema = { + parse(value: unknown): NylasConfig { + return parseNylasConfig(value); + }, + uiHints: { + apiKey: { + label: "API Key", + help: "Nylas API key from dashboard.nylas.com", + sensitive: true, + }, + apiUri: { + label: "API URI", + help: "Use https://api.us.nylas.com (US) or https://api.eu.nylas.com (EU)", + placeholder: "https://api.us.nylas.com", + }, + defaultGrantId: { + label: "Default Grant ID", + help: "Primary email account's grant ID from Nylas dashboard", + }, + defaultTimezone: { + label: "Default Timezone", + help: "Timezone for date/time operations (e.g., America/New_York)", + placeholder: "America/New_York", + }, + grants: { + label: "Named Grants", + help: "Map of named grants for multi-account access", + advanced: true, + }, + }, +}; + +const nylasPlugin = { + id: "nylas", + name: "Nylas", + description: "Email, calendar, and contacts integration via Nylas API v3", + configSchema: nylasConfigSchema, + register(api) { + const config = nylasConfigSchema.parse(api.pluginConfig); + const validation = validateNylasConfig(config); + + // Capture validation errors early for use in getClient closure + const validationErrors = !validation.valid ? validation.errors : null; + + // Create client lazily + let client: NylasClient | null = null; + + const getClient = () => { + if (!client) { + if (!config.enabled) { + throw new Error("Nylas plugin is disabled"); + } + if (validationErrors) { + throw new Error(`Nylas configuration error: ${validationErrors.join("; ")}`); + } + client = new NylasClient({ + config, + logger: api.logger, + }); + } + return client; + }; + + // Register all tools + for (const toolDef of nylasTools) { + api.registerTool({ + name: toolDef.name, + label: toolDef.label, + description: toolDef.description, + parameters: toolDef.parameters, + async execute(_toolCallId, params) { + const json = (payload: unknown) => ({ + content: [ + { type: "text", text: JSON.stringify(payload, null, 2) }, + ], + details: payload, + }); + + try { + const result = await toolDef.execute(getClient(), params); + return json(result); + } catch (err) { + if (err instanceof NylasApiError) { + return json({ + error: err.message, + status_code: err.statusCode, + error_type: err.errorType, + request_id: err.requestId, + }); + } + return json({ + error: err instanceof Error ? err.message : String(err), + }); + } + }, + }); + } + + // Register CLI commands + api.registerCli( + ({ program, logger }) => + registerNylasCli({ + program, + config, + logger, + }), + { commands: ["nylas"] }, + ); + + // Register gateway methods for programmatic access + api.registerGatewayMethod("nylas.discoverGrants", async ({ params, respond }) => { + try { + const c = getClient(); + const result = await c.listGrants(params ?? {}); + respond(true, { + grants: result.data.map((g) => ({ + id: g.id, + email: g.email, + provider: g.provider, + status: g.grant_status, + scopes: g.scope, + })), + }); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + + api.registerGatewayMethod("nylas.listEmails", async ({ params, respond }) => { + try { + const c = getClient(); + const result = await c.listMessages(params ?? {}); + respond(true, result); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + + api.registerGatewayMethod("nylas.getMessage", async ({ params, respond }) => { + try { + const c = getClient(); + const messageId = typeof params?.messageId === "string" ? params.messageId : ""; + const grant = typeof params?.grant === "string" ? params.grant : undefined; + if (!messageId) { + respond(false, { error: "messageId required" }); + return; + } + const result = await c.getMessage(messageId, grant); + respond(true, result); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + + api.registerGatewayMethod("nylas.sendMessage", async ({ params, respond }) => { + try { + const c = getClient(); + const result = await c.sendMessage(params ?? {}); + respond(true, result); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + + api.registerGatewayMethod("nylas.listCalendars", async ({ params, respond }) => { + try { + const c = getClient(); + const grant = typeof params?.grant === "string" ? params.grant : undefined; + const result = await c.listCalendars(grant); + respond(true, result); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + + api.registerGatewayMethod("nylas.listEvents", async ({ params, respond }) => { + try { + const c = getClient(); + const result = await c.listEvents(params ?? {}); + respond(true, result); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + + api.registerGatewayMethod("nylas.createEvent", async ({ params, respond }) => { + try { + const c = getClient(); + const result = await c.createEvent(params as Parameters[0]); + respond(true, result); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + + api.registerGatewayMethod("nylas.listContacts", async ({ params, respond }) => { + try { + const c = getClient(); + const result = await c.listContacts(params ?? {}); + respond(true, result); + } catch (err) { + respond(false, { error: err instanceof Error ? err.message : String(err) }); + } + }); + }, +}; + +export default nylasPlugin; diff --git a/extensions/nylas/package.json b/extensions/nylas/package.json new file mode 100644 index 000000000..b6218fe5b --- /dev/null +++ b/extensions/nylas/package.json @@ -0,0 +1,16 @@ +{ + "name": "@clawdbot/nylas", + "version": "2026.1.26", + "type": "module", + "description": "Clawdbot Nylas API v3 plugin for email, calendar, and contacts", + "dependencies": { + "@sinclair/typebox": "0.34.47", + "nylas": "^7.5.2", + "zod": "^4.3.6" + }, + "clawdbot": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/nylas/src/cli.ts b/extensions/nylas/src/cli.ts new file mode 100644 index 000000000..56bb1d028 --- /dev/null +++ b/extensions/nylas/src/cli.ts @@ -0,0 +1,362 @@ +import type { Command } from "commander"; +import type { NylasConfig } from "./config.js"; +import { NylasClient, NylasApiError } from "./client.js"; + +type NylasCliContext = { + program: Command; + config: NylasConfig; + logger: { + info: (msg: string) => void; + warn: (msg: string) => void; + error: (msg: string) => void; + }; +}; + +export function registerNylasCli(ctx: NylasCliContext): void { + const { program, config, logger } = ctx; + + const nylas = program + .command("nylas") + .description("Nylas email, calendar, and contacts integration"); + + // Status command - auto-discovers grants from API + nylas + .command("status") + .description("Check Nylas API connection and discover available grants") + .action(async () => { + logger.info("Nylas Plugin Status"); + logger.info("==================="); + logger.info(""); + + // Check configuration + logger.info("Configuration:"); + logger.info(` API URI: ${config.apiUri}`); + logger.info(` API Key: ${config.apiKey ? "configured" : "NOT CONFIGURED"}`); + logger.info(` Default Grant ID: ${config.defaultGrantId ?? "not set"}`); + logger.info(` Default Timezone: ${config.defaultTimezone}`); + + const namedGrants = Object.keys(config.grants); + if (namedGrants.length > 0) { + logger.info(` Named Grants: ${namedGrants.join(", ")}`); + } + + logger.info(""); + + // Check API connection and discover grants + if (!config.apiKey) { + logger.error("Cannot connect to API - apiKey not configured"); + logger.info(""); + logger.info("Add your API key to clawdbot.yaml:"); + logger.info(" plugins:"); + logger.info(" entries:"); + logger.info(" nylas:"); + logger.info(" config:"); + logger.info(" apiKey: nyk_v0_your_api_key_here"); + return; + } + + logger.info("Connecting to Nylas API..."); + + try { + const client = new NylasClient({ config, logger }); + + // Discover all grants associated with this API key + const grantsResponse = await client.listGrants(); + const grants = grantsResponse.data; + + logger.info(` Connection: OK`); + logger.info(""); + + if (grants.length === 0) { + logger.warn("No grants found. You need to authenticate email accounts in the Nylas dashboard."); + logger.info(""); + logger.info("1. Go to https://dashboard.nylas.com"); + logger.info("2. Navigate to Grants section"); + logger.info("3. Click 'Add Account' to authenticate your email"); + return; + } + + logger.info(`Discovered ${grants.length} authenticated account(s):`); + logger.info(""); + + for (const grant of grants) { + const status = grant.grantStatus === "valid" ? "active" : grant.grantStatus; + logger.info(` Email: ${grant.email}`); + logger.info(` Grant ID: ${grant.id}`); + logger.info(` Provider: ${grant.provider}`); + logger.info(` Status: ${status}`); + if (grant.scope && grant.scope.length > 0) { + logger.info(` Scopes: ${grant.scope.join(", ")}`); + } + logger.info(""); + } + + // Show recommended configuration + if (!config.defaultGrantId && grants.length > 0) { + logger.info("Recommended configuration for clawdbot.yaml:"); + logger.info(""); + logger.info(" plugins:"); + logger.info(" entries:"); + logger.info(" nylas:"); + logger.info(" config:"); + logger.info(` apiKey: ${config.apiKey.slice(0, 10)}...`); + logger.info(` defaultGrantId: ${grants[0].id}`); + + if (grants.length > 1) { + logger.info(" grants:"); + for (const grant of grants) { + const name = grant.email.split("@")[0].replace(/[^a-z0-9]/gi, "_").toLowerCase(); + logger.info(` ${name}: ${grant.id}`); + } + } + } + } catch (err) { + if (err instanceof NylasApiError) { + logger.error(` Connection: FAILED`); + logger.error(` Error: ${err.message}`); + logger.error(` Status: ${err.statusCode}`); + if (err.requestId) { + logger.error(` Request ID: ${err.requestId}`); + } + if (err.statusCode === 401) { + logger.info(""); + logger.info("Your API key may be invalid or expired."); + logger.info("Get a new key from https://dashboard.nylas.com/api-keys"); + } + } else { + logger.error(` Connection: FAILED`); + logger.error(` Error: ${err instanceof Error ? err.message : String(err)}`); + } + } + }); + + // Discover command - fetch grants from API + nylas + .command("discover") + .description("Discover all authenticated accounts from Nylas API") + .option("--json", "Output as JSON") + .action(async (opts: { json?: boolean }) => { + if (!config.apiKey) { + if (opts.json) { + console.log(JSON.stringify({ error: "API key not configured" })); + } else { + logger.error("API key not configured. Set plugins.entries.nylas.config.apiKey"); + } + return; + } + + try { + const client = new NylasClient({ config, logger }); + const grantsResponse = await client.listGrants(); + const grants = grantsResponse.data; + + if (opts.json) { + console.log(JSON.stringify({ + grants: grants.map((g) => ({ + id: g.id, + email: g.email, + provider: g.provider, + status: g.grantStatus, + scopes: g.scope, + })), + }, null, 2)); + return; + } + + if (grants.length === 0) { + logger.warn("No authenticated accounts found."); + logger.info(""); + logger.info("Authenticate accounts at https://dashboard.nylas.com → Grants → Add Account"); + return; + } + + logger.info(`Found ${grants.length} authenticated account(s):`); + logger.info(""); + + for (const grant of grants) { + const status = grant.grantStatus === "valid" ? "active" : grant.grantStatus; + logger.info(`${grant.email}`); + logger.info(` ID: ${grant.id}`); + logger.info(` Provider: ${grant.provider} | Status: ${status}`); + logger.info(""); + } + + // Generate config snippet + logger.info("Add to clawdbot.yaml:"); + logger.info(""); + logger.info("plugins:"); + logger.info(" entries:"); + logger.info(" nylas:"); + logger.info(" config:"); + logger.info(` apiKey: "${config.apiKey}"`); + logger.info(` defaultGrantId: "${grants[0].id}"`); + + if (grants.length > 1) { + logger.info(" grants:"); + for (const grant of grants) { + const name = grant.email.split("@")[0].replace(/[^a-z0-9]/gi, "_").toLowerCase(); + logger.info(` ${name}: "${grant.id}"`); + } + } + } catch (err) { + if (opts.json) { + console.log(JSON.stringify({ + error: err instanceof Error ? err.message : String(err), + })); + } else if (err instanceof NylasApiError) { + logger.error(`API Error: ${err.message} (${err.statusCode})`); + } else { + logger.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + } + }); + + // Test command + nylas + .command("test") + .description("Test Nylas API with a specific grant") + .option("-g, --grant ", "Grant name or ID to test") + .action(async (opts: { grant?: string }) => { + if (!config.apiKey) { + logger.error("API key not configured. Set plugins.entries.nylas.config.apiKey"); + return; + } + + try { + const client = new NylasClient({ config, logger }); + + // If no grant specified and no default, try to discover one + let grantId = opts.grant ?? config.defaultGrantId ?? Object.values(config.grants)[0]; + + if (!grantId) { + logger.info("No grant configured, discovering from API..."); + const grantsResponse = await client.listGrants({ limit: 1 }); + if (grantsResponse.data.length === 0) { + logger.error("No grants found. Authenticate an account at https://dashboard.nylas.com"); + return; + } + grantId = grantsResponse.data[0].id; + logger.info(`Using discovered grant: ${grantsResponse.data[0].email}`); + } + + logger.info(`Testing grant: ${grantId}`); + logger.info(""); + + // Get grant details + const grantDetails = await client.getGrant(grantId); + logger.info(`Account: ${grantDetails.data.email}`); + logger.info(`Provider: ${grantDetails.data.provider}`); + logger.info(`Status: ${grantDetails.data.grantStatus}`); + logger.info(""); + + // Test calendars + logger.info("Calendars:"); + const calendars = await client.listCalendars(grantId); + for (const cal of calendars.data.slice(0, 5)) { + const primary = cal.is_primary ? " (primary)" : ""; + logger.info(` - ${cal.name}${primary}`); + } + if (calendars.data.length > 5) { + logger.info(` ... and ${calendars.data.length - 5} more`); + } + + logger.info(""); + + // Test folders + logger.info("Email Folders:"); + const folders = await client.listFolders(grantId); + for (const folder of folders.data.slice(0, 10)) { + const count = folder.total_count !== undefined ? ` (${folder.total_count} emails)` : ""; + logger.info(` - ${folder.name}${count}`); + } + if (folders.data.length > 10) { + logger.info(` ... and ${folders.data.length - 10} more`); + } + + logger.info(""); + + // Test recent emails + logger.info("Recent Emails:"); + const messages = await client.listMessages({ grant: grantId, limit: 5 }); + for (const msg of messages.data) { + const from = msg.from?.[0]?.email ?? "unknown"; + const subject = msg.subject ?? "(no subject)"; + const date = new Date(msg.date * 1000).toLocaleDateString(); + logger.info(` - ${date} | ${from} | ${subject.slice(0, 50)}`); + } + + logger.info(""); + logger.info("All tests passed."); + } catch (err) { + if (err instanceof NylasApiError) { + logger.error(`API Error: ${err.message}`); + logger.error(`Status: ${err.statusCode}`); + } else { + logger.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + } + }); + + // Grants command - shows both configured and discovered grants + nylas + .command("grants") + .description("List configured and available grants") + .option("--configured", "Show only configured grants") + .action(async (opts: { configured?: boolean }) => { + logger.info("Nylas Grants"); + logger.info("============"); + logger.info(""); + + // Show configured grants + logger.info("Configured:"); + if (config.defaultGrantId) { + logger.info(` Default: ${config.defaultGrantId}`); + } + + const namedGrants = Object.entries(config.grants); + if (namedGrants.length > 0) { + for (const [name, id] of namedGrants) { + logger.info(` ${name}: ${id}`); + } + } + + if (!config.defaultGrantId && namedGrants.length === 0) { + logger.info(" (none)"); + } + + if (opts.configured) { + return; + } + + logger.info(""); + + // Fetch from API + if (!config.apiKey) { + logger.warn("Cannot discover grants - apiKey not configured"); + return; + } + + try { + const client = new NylasClient({ config, logger }); + const grantsResponse = await client.listGrants(); + const grants = grantsResponse.data; + + logger.info("Available from API:"); + if (grants.length === 0) { + logger.info(" (none - authenticate accounts at dashboard.nylas.com)"); + return; + } + + for (const grant of grants) { + const status = grant.grantStatus === "valid" ? "" : ` [${grant.grantStatus}]`; + logger.info(` ${grant.email}: ${grant.id}${status}`); + } + } catch (err) { + if (err instanceof NylasApiError) { + logger.error(`Failed to fetch grants: ${err.message}`); + } else { + logger.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + } + }); +} diff --git a/extensions/nylas/src/client.ts b/extensions/nylas/src/client.ts new file mode 100644 index 000000000..82d63ec37 --- /dev/null +++ b/extensions/nylas/src/client.ts @@ -0,0 +1,534 @@ +import Nylas from "nylas"; +import type { NylasConfig } from "./config.js"; + +/** + * Filter out undefined values from an object. + * The Nylas SDK sends undefined values as query params, causing API errors. + */ +function filterDefined>(obj: T): Partial { + const result: Partial = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined) { + (result as any)[key] = value; + } + } + return result; +} + +export type NylasClientOptions = { + config: NylasConfig; + logger?: { + debug?: (message: string) => void; + error: (message: string) => void; + }; +}; + +export class NylasApiError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly requestId?: string, + public readonly errorType?: string, + ) { + super(message); + this.name = "NylasApiError"; + } +} + +/** + * Wrapper around the official Nylas SDK that handles grant resolution + * and provides a simplified interface for the plugin. + */ +export class NylasClient { + public readonly sdk: Nylas; + private readonly defaultGrantId?: string; + private readonly grants: Record; + private readonly logger?: NylasClientOptions["logger"]; + + constructor(options: NylasClientOptions) { + const { config, logger } = options; + + this.sdk = new Nylas({ + apiKey: config.apiKey ?? "", + apiUri: config.apiUri, + }); + + this.defaultGrantId = config.defaultGrantId; + this.grants = config.grants; + this.logger = logger; + } + + /** + * Resolve a grant name or ID to an actual grant ID. + * If no grant is provided, uses the default grant ID. + */ + resolveGrantId(grantIdOrName?: string): string { + if (!grantIdOrName) { + if (!this.defaultGrantId) { + throw new NylasApiError("No grant ID provided and no defaultGrantId configured"); + } + return this.defaultGrantId; + } + + // Check if it's a named grant + if (this.grants[grantIdOrName]) { + return this.grants[grantIdOrName]; + } + + // Assume it's a raw grant ID + return grantIdOrName; + } + + // ========================================================================== + // Grants (Account Discovery) - doesn't require a grant ID + // ========================================================================== + + async listGrants(params?: { + limit?: number; + offset?: number; + provider?: string; + email?: string; + }) { + this.logger?.debug?.(`[nylas] listGrants`); + try { + const queryParams = params + ? filterDefined({ + limit: params.limit, + offset: params.offset, + provider: params.provider as any, + email: params.email, + }) + : undefined; + return await this.sdk.grants.list({ + queryParams: Object.keys(queryParams ?? {}).length > 0 ? queryParams : undefined, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async getGrant(grantId: string) { + this.logger?.debug?.(`[nylas] getGrant ${grantId}`); + try { + return await this.sdk.grants.find({ grantId }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Messages + // ========================================================================== + + async listMessages(params?: { + grant?: string; + limit?: number; + pageToken?: string; + subject?: string; + from?: string; + to?: string; + in?: string; + unread?: boolean; + starred?: boolean; + hasAttachment?: boolean; + receivedBefore?: number; + receivedAfter?: number; + }) { + const grantId = this.resolveGrantId(params?.grant); + this.logger?.debug?.(`[nylas] listMessages for ${grantId}`); + try { + const queryParams = params + ? filterDefined({ + limit: params.limit, + pageToken: params.pageToken, + subject: params.subject, + from: params.from, + to: params.to, + in: params.in, + unread: params.unread, + starred: params.starred, + hasAttachment: params.hasAttachment, + receivedBefore: params.receivedBefore, + receivedAfter: params.receivedAfter, + }) + : undefined; + return await this.sdk.messages.list({ + identifier: grantId, + queryParams: Object.keys(queryParams ?? {}).length > 0 ? queryParams : undefined, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async getMessage(messageId: string, grant?: string) { + const grantId = this.resolveGrantId(grant); + this.logger?.debug?.(`[nylas] getMessage ${messageId}`); + try { + return await this.sdk.messages.find({ + identifier: grantId, + messageId, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async sendMessage(params: { + grant?: string; + to?: Array<{ email: string; name?: string }>; + cc?: Array<{ email: string; name?: string }>; + bcc?: Array<{ email: string; name?: string }>; + replyTo?: Array<{ email: string; name?: string }>; + subject?: string; + body?: string; + replyToMessageId?: string; + }) { + const grantId = this.resolveGrantId(params.grant); + this.logger?.debug?.(`[nylas] sendMessage`); + try { + return await this.sdk.messages.send({ + identifier: grantId, + requestBody: { + to: params.to, + cc: params.cc, + bcc: params.bcc, + replyTo: params.replyTo, + subject: params.subject, + body: params.body, + replyToMessageId: params.replyToMessageId, + }, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Threads + // ========================================================================== + + async listThreads(params?: { + grant?: string; + limit?: number; + pageToken?: string; + subject?: string; + from?: string; + to?: string; + in?: string; + unread?: boolean; + starred?: boolean; + }) { + const grantId = this.resolveGrantId(params?.grant); + this.logger?.debug?.(`[nylas] listThreads for ${grantId}`); + try { + const queryParams = params + ? filterDefined({ + limit: params.limit, + pageToken: params.pageToken, + subject: params.subject, + from: params.from, + to: params.to, + in: params.in, + unread: params.unread, + starred: params.starred, + }) + : undefined; + return await this.sdk.threads.list({ + identifier: grantId, + queryParams: Object.keys(queryParams ?? {}).length > 0 ? queryParams : undefined, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Folders + // ========================================================================== + + async listFolders(grant?: string) { + const grantId = this.resolveGrantId(grant); + this.logger?.debug?.(`[nylas] listFolders for ${grantId}`); + try { + return await this.sdk.folders.list({ identifier: grantId }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Drafts + // ========================================================================== + + async createDraft(params: { + grant?: string; + to?: Array<{ email: string; name?: string }>; + cc?: Array<{ email: string; name?: string }>; + bcc?: Array<{ email: string; name?: string }>; + subject?: string; + body?: string; + replyToMessageId?: string; + }) { + const grantId = this.resolveGrantId(params.grant); + this.logger?.debug?.(`[nylas] createDraft`); + try { + return await this.sdk.drafts.create({ + identifier: grantId, + requestBody: { + to: params.to, + cc: params.cc, + bcc: params.bcc, + subject: params.subject, + body: params.body, + replyToMessageId: params.replyToMessageId, + }, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Calendars + // ========================================================================== + + async listCalendars(grant?: string) { + const grantId = this.resolveGrantId(grant); + this.logger?.debug?.(`[nylas] listCalendars for ${grantId}`); + try { + return await this.sdk.calendars.list({ identifier: grantId }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Events + // ========================================================================== + + async listEvents(params?: { + grant?: string; + calendarId?: string; + limit?: number; + pageToken?: string; + start?: number; + end?: number; + title?: string; + showCancelled?: boolean; + expandRecurring?: boolean; + }) { + const grantId = this.resolveGrantId(params?.grant); + this.logger?.debug?.(`[nylas] listEvents for ${grantId}`); + try { + const queryParams = filterDefined({ + calendarId: params?.calendarId ?? "primary", + limit: params?.limit, + pageToken: params?.pageToken, + start: params?.start, + end: params?.end, + title: params?.title, + showCancelled: params?.showCancelled, + expandRecurring: params?.expandRecurring, + }); + return await this.sdk.events.list({ + identifier: grantId, + queryParams: queryParams as any, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async getEvent(eventId: string, calendarId: string, grant?: string) { + const grantId = this.resolveGrantId(grant); + this.logger?.debug?.(`[nylas] getEvent ${eventId}`); + try { + return await this.sdk.events.find({ + identifier: grantId, + eventId, + queryParams: { calendarId }, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async createEvent(params: { + grant?: string; + calendarId: string; + title?: string; + description?: string; + location?: string; + when: any; // SDK's When type + participants?: Array<{ email: string; name?: string }>; + busy?: boolean; + }) { + const grantId = this.resolveGrantId(params.grant); + this.logger?.debug?.(`[nylas] createEvent`); + try { + return await this.sdk.events.create({ + identifier: grantId, + queryParams: { calendarId: params.calendarId }, + requestBody: { + title: params.title, + description: params.description, + location: params.location, + when: params.when, + participants: params.participants, + busy: params.busy, + }, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async updateEvent( + eventId: string, + calendarId: string, + params: { + grant?: string; + title?: string; + description?: string; + location?: string; + when?: any; + participants?: Array<{ email: string; name?: string }>; + busy?: boolean; + }, + ) { + const grantId = this.resolveGrantId(params.grant); + this.logger?.debug?.(`[nylas] updateEvent ${eventId}`); + try { + return await this.sdk.events.update({ + identifier: grantId, + eventId, + queryParams: { calendarId }, + requestBody: { + title: params.title, + description: params.description, + location: params.location, + when: params.when, + participants: params.participants, + busy: params.busy, + }, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async deleteEvent(eventId: string, calendarId: string, grant?: string) { + const grantId = this.resolveGrantId(grant); + this.logger?.debug?.(`[nylas] deleteEvent ${eventId}`); + try { + await this.sdk.events.destroy({ + identifier: grantId, + eventId, + queryParams: { calendarId }, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Availability + // ========================================================================== + + async checkAvailability(params: { + startTime: number; + endTime: number; + participants: Array<{ email: string }>; + durationMinutes: number; + intervalMinutes?: number; + }) { + this.logger?.debug?.(`[nylas] checkAvailability`); + try { + return await this.sdk.calendars.getAvailability({ + requestBody: { + startTime: params.startTime, + endTime: params.endTime, + participants: params.participants.map((p) => ({ email: p.email })), + durationMinutes: params.durationMinutes, + intervalMinutes: params.intervalMinutes, + }, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Contacts + // ========================================================================== + + async listContacts(params?: { + grant?: string; + limit?: number; + pageToken?: string; + email?: string; + phoneNumber?: string; + source?: string; + group?: string; + }) { + const grantId = this.resolveGrantId(params?.grant); + this.logger?.debug?.(`[nylas] listContacts for ${grantId}`); + try { + const queryParams = params + ? filterDefined({ + limit: params.limit, + pageToken: params.pageToken, + email: params.email, + phoneNumber: params.phoneNumber, + source: params.source as any, + group: params.group, + }) + : undefined; + return await this.sdk.contacts.list({ + identifier: grantId, + queryParams: Object.keys(queryParams ?? {}).length > 0 ? queryParams : undefined, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + async getContact(contactId: string, grant?: string) { + const grantId = this.resolveGrantId(grant); + this.logger?.debug?.(`[nylas] getContact ${contactId}`); + try { + return await this.sdk.contacts.find({ + identifier: grantId, + contactId, + }); + } catch (err) { + throw this.wrapError(err); + } + } + + // ========================================================================== + // Error handling + // ========================================================================== + + private wrapError(err: unknown): NylasApiError { + if (err instanceof NylasApiError) { + return err; + } + + // Handle Nylas SDK errors + const error = err as any; + if (error?.statusCode || error?.requestId) { + return new NylasApiError( + error.message ?? String(err), + error.statusCode, + error.requestId, + error.type, + ); + } + + return new NylasApiError( + err instanceof Error ? err.message : String(err), + ); + } +} diff --git a/extensions/nylas/src/config.ts b/extensions/nylas/src/config.ts new file mode 100644 index 000000000..ea5e3dc3c --- /dev/null +++ b/extensions/nylas/src/config.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +export const NylasConfigSchema = z.object({ + enabled: z.boolean().default(true), + apiKey: z.string().optional(), + apiUri: z.string().default("https://api.us.nylas.com"), + defaultGrantId: z.string().optional(), + defaultTimezone: z.string().default("UTC"), + grants: z.record(z.string(), z.string()).default({}), +}); + +export type NylasConfig = z.infer; + +export function parseNylasConfig(value: unknown): NylasConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + + return NylasConfigSchema.parse({ + enabled: typeof raw.enabled === "boolean" ? raw.enabled : true, + apiKey: typeof raw.apiKey === "string" ? raw.apiKey : undefined, + apiUri: typeof raw.apiUri === "string" ? raw.apiUri : "https://api.us.nylas.com", + defaultGrantId: typeof raw.defaultGrantId === "string" ? raw.defaultGrantId : undefined, + defaultTimezone: typeof raw.defaultTimezone === "string" ? raw.defaultTimezone : "UTC", + grants: raw.grants && typeof raw.grants === "object" ? (raw.grants as Record) : {}, + }); +} + +export type NylasConfigValidation = + | { valid: true } + | { valid: false; errors: string[] }; + +export function validateNylasConfig(config: NylasConfig): NylasConfigValidation { + const errors: string[] = []; + + if (!config.apiKey) { + errors.push("apiKey is required"); + } + + if (!config.defaultGrantId && Object.keys(config.grants).length === 0) { + errors.push("defaultGrantId or at least one named grant is required"); + } + + return errors.length > 0 ? { valid: false, errors } : { valid: true }; +} diff --git a/extensions/nylas/src/tools/calendar.ts b/extensions/nylas/src/tools/calendar.ts new file mode 100644 index 000000000..b34f18121 --- /dev/null +++ b/extensions/nylas/src/tools/calendar.ts @@ -0,0 +1,339 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { NylasClient } from "../client.js"; + +// Helper to format participant +function formatParticipant(p: { email: string; name?: string; status?: string }): string { + const parts = [p.email]; + if (p.name) parts.unshift(p.name); + if (p.status && p.status !== "noreply") parts.push(`(${p.status})`); + return parts.join(" "); +} + +// Helper to parse participant emails +function parseParticipants(emails: string | string[] | undefined): Array<{ email: string }> | undefined { + if (!emails) return undefined; + const items = Array.isArray(emails) ? emails : emails.split(",").map((s) => s.trim()); + return items.filter(Boolean).map((email) => ({ email })); +} + +// Helper to format event time for display +function formatEventWhen(when: any): { start: string; end?: string; all_day?: boolean } { + if (!when) return { start: "unknown" }; + + // Handle different "when" types from Nylas SDK + if (when.date) { + return { start: when.date, all_day: true }; + } + if (when.startDate && when.endDate) { + return { start: when.startDate, end: when.endDate, all_day: true }; + } + if (when.time) { + return { start: new Date(when.time * 1000).toISOString() }; + } + if (when.startTime && when.endTime) { + return { + start: new Date(when.startTime * 1000).toISOString(), + end: new Date(when.endTime * 1000).toISOString(), + }; + } + + return { start: JSON.stringify(when) }; +} + +// Helper to create "when" object for SDK +function createWhen( + start: string, + end?: string, + allDay?: boolean, + timezone?: string, +): any { + if (allDay) { + if (end) { + return { + startDate: start.slice(0, 10), + endDate: end.slice(0, 10), + }; + } + return { date: start.slice(0, 10) }; + } + + const startTime = Math.floor(new Date(start).getTime() / 1000); + if (end) { + const endTime = Math.floor(new Date(end).getTime() / 1000); + return { + startTime, + endTime, + startTimezone: timezone, + endTimezone: timezone, + }; + } + return { time: startTime, timezone }; +} + +// ============================================================================= +// List Calendars Tool +// ============================================================================= + +export const ListCalendarsSchema = Type.Object({ + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type ListCalendarsParams = Static; + +export async function listCalendars(client: NylasClient, params: ListCalendarsParams) { + const response = await client.listCalendars(params.grant); + + const calendars = response.data.map((cal) => ({ + id: cal.id, + name: cal.name, + description: cal.description, + is_primary: cal.isPrimary, + timezone: cal.timezone, + read_only: cal.readOnly, + })); + + return { calendars }; +} + +// ============================================================================= +// List Events Tool +// ============================================================================= + +export const ListEventsSchema = Type.Object({ + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), + calendar_id: Type.Optional(Type.String({ description: "Calendar ID to list events from (uses primary if not specified)" })), + start: Type.Optional(Type.String({ description: "ISO 8601 date/time - events starting after this time" })), + end: Type.Optional(Type.String({ description: "ISO 8601 date/time - events ending before this time" })), + title: Type.Optional(Type.String({ description: "Filter by event title (partial match)" })), + show_cancelled: Type.Optional(Type.Boolean({ description: "Include cancelled events" })), + expand_recurring: Type.Optional(Type.Boolean({ description: "Expand recurring events into individual instances" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 25, max: 100)" })), + page_token: Type.Optional(Type.String({ description: "Pagination token for next page" })), +}); + +export type ListEventsParams = Static; + +export async function listEvents(client: NylasClient, params: ListEventsParams) { + const limit = Math.min(params.limit ?? 25, 100); + + const response = await client.listEvents({ + grant: params.grant, + calendarId: params.calendar_id, + limit, + pageToken: params.page_token, + title: params.title, + start: params.start ? Math.floor(new Date(params.start).getTime() / 1000) : undefined, + end: params.end ? Math.floor(new Date(params.end).getTime() / 1000) : undefined, + showCancelled: params.show_cancelled, + expandRecurring: params.expand_recurring, + }); + + const events = response.data.map((event) => { + const time = formatEventWhen(event.when); + return { + id: event.id, + calendar_id: event.calendarId, + title: event.title ?? "(no title)", + description: event.description, + location: event.location, + start: time.start, + end: time.end, + all_day: time.all_day, + participants: event.participants?.map(formatParticipant), + status: event.status, + busy: event.busy, + conferencing_url: event.conferencing?.details?.url, + }; + }); + + return { + events, + has_more: !!response.nextCursor, + next_page_token: response.nextCursor, + count: events.length, + }; +} + +// ============================================================================= +// Get Event Tool +// ============================================================================= + +export const GetEventSchema = Type.Object({ + event_id: Type.String({ description: "The event ID" }), + calendar_id: Type.String({ description: "The calendar ID the event belongs to" }), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type GetEventParams = Static; + +export async function getEvent(client: NylasClient, params: GetEventParams) { + const response = await client.getEvent(params.event_id, params.calendar_id, params.grant); + const event = response.data; + const time = formatEventWhen(event.when); + + return { + id: event.id, + calendar_id: event.calendarId, + title: event.title ?? "(no title)", + description: event.description, + location: event.location, + start: time.start, + end: time.end, + all_day: time.all_day, + participants: event.participants?.map(formatParticipant), + status: event.status, + visibility: event.visibility, + busy: event.busy, + read_only: event.readOnly, + conferencing: event.conferencing, + created_at: event.createdAt ? new Date(event.createdAt * 1000).toISOString() : undefined, + updated_at: event.updatedAt ? new Date(event.updatedAt * 1000).toISOString() : undefined, + }; +} + +// ============================================================================= +// Create Event Tool +// ============================================================================= + +export const CreateEventSchema = Type.Object({ + calendar_id: Type.String({ description: "Calendar ID to create the event in" }), + title: Type.String({ description: "Event title" }), + start: Type.String({ description: "ISO 8601 start date/time (e.g., '2024-03-20T10:00:00Z' or '2024-03-20' for all-day)" }), + end: Type.Optional(Type.String({ description: "ISO 8601 end date/time" })), + all_day: Type.Optional(Type.Boolean({ description: "Whether this is an all-day event" })), + timezone: Type.Optional(Type.String({ description: "Timezone for the event (e.g., 'America/New_York')" })), + description: Type.Optional(Type.String({ description: "Event description" })), + location: Type.Optional(Type.String({ description: "Event location" })), + participants: Type.Optional(Type.String({ description: "Comma-separated list of participant emails to invite" })), + busy: Type.Optional(Type.Boolean({ description: "Whether to show as busy during this event (default: true)" })), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type CreateEventParams = Static; + +export async function createEvent(client: NylasClient, params: CreateEventParams) { + const response = await client.createEvent({ + grant: params.grant, + calendarId: params.calendar_id, + title: params.title, + when: createWhen(params.start, params.end, params.all_day, params.timezone), + description: params.description, + location: params.location, + participants: parseParticipants(params.participants), + busy: params.busy ?? true, + }); + + const event = response.data; + const time = formatEventWhen(event.when); + + return { + success: true, + event_id: event.id, + calendar_id: event.calendarId, + title: event.title, + start: time.start, + end: time.end, + all_day: time.all_day, + }; +} + +// ============================================================================= +// Update Event Tool +// ============================================================================= + +export const UpdateEventSchema = Type.Object({ + event_id: Type.String({ description: "The event ID to update" }), + calendar_id: Type.String({ description: "The calendar ID the event belongs to" }), + title: Type.Optional(Type.String({ description: "New event title" })), + start: Type.Optional(Type.String({ description: "New ISO 8601 start date/time" })), + end: Type.Optional(Type.String({ description: "New ISO 8601 end date/time" })), + all_day: Type.Optional(Type.Boolean({ description: "Whether this is an all-day event" })), + timezone: Type.Optional(Type.String({ description: "Timezone for the event" })), + description: Type.Optional(Type.String({ description: "New event description" })), + location: Type.Optional(Type.String({ description: "New event location" })), + participants: Type.Optional(Type.String({ description: "New comma-separated list of participant emails" })), + busy: Type.Optional(Type.Boolean({ description: "Whether to show as busy during this event" })), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type UpdateEventParams = Static; + +export async function updateEvent(client: NylasClient, params: UpdateEventParams) { + const updateData: any = { grant: params.grant }; + + if (params.title !== undefined) updateData.title = params.title; + if (params.description !== undefined) updateData.description = params.description; + if (params.location !== undefined) updateData.location = params.location; + if (params.busy !== undefined) updateData.busy = params.busy; + if (params.participants !== undefined) updateData.participants = parseParticipants(params.participants); + if (params.start !== undefined) { + updateData.when = createWhen(params.start, params.end, params.all_day, params.timezone); + } + + const response = await client.updateEvent(params.event_id, params.calendar_id, updateData); + const event = response.data; + const time = formatEventWhen(event.when); + + return { + success: true, + event_id: event.id, + title: event.title, + start: time.start, + end: time.end, + }; +} + +// ============================================================================= +// Delete Event Tool +// ============================================================================= + +export const DeleteEventSchema = Type.Object({ + event_id: Type.String({ description: "The event ID to delete" }), + calendar_id: Type.String({ description: "The calendar ID the event belongs to" }), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type DeleteEventParams = Static; + +export async function deleteEvent(client: NylasClient, params: DeleteEventParams) { + await client.deleteEvent(params.event_id, params.calendar_id, params.grant); + return { success: true, event_id: params.event_id }; +} + +// ============================================================================= +// Check Availability Tool +// ============================================================================= + +export const CheckAvailabilitySchema = Type.Object({ + emails: Type.String({ description: "Comma-separated list of participant emails to check availability for" }), + start: Type.String({ description: "ISO 8601 start date/time for availability window" }), + end: Type.String({ description: "ISO 8601 end date/time for availability window" }), + duration_minutes: Type.Number({ description: "Required meeting duration in minutes" }), + interval_minutes: Type.Optional(Type.Number({ description: "Interval between time slots in minutes (default: 15)" })), +}); + +export type CheckAvailabilityParams = Static; + +export async function checkAvailability(client: NylasClient, params: CheckAvailabilityParams) { + const emails = params.emails.split(",").map((e) => e.trim()).filter(Boolean); + + const response = await client.checkAvailability({ + startTime: Math.floor(new Date(params.start).getTime() / 1000), + endTime: Math.floor(new Date(params.end).getTime() / 1000), + durationMinutes: params.duration_minutes, + intervalMinutes: params.interval_minutes ?? 15, + participants: emails.map((email) => ({ email })), + }); + + const slots = response.data.timeSlots?.map((slot) => ({ + start: new Date(slot.startTime * 1000).toISOString(), + end: new Date(slot.endTime * 1000).toISOString(), + available_for: slot.emails, + })) ?? []; + + return { + available_slots: slots, + checked_emails: response.data.order, + slot_count: slots.length, + }; +} diff --git a/extensions/nylas/src/tools/contacts.ts b/extensions/nylas/src/tools/contacts.ts new file mode 100644 index 000000000..4e15c9eb5 --- /dev/null +++ b/extensions/nylas/src/tools/contacts.ts @@ -0,0 +1,101 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { NylasClient } from "../client.js"; + +// ============================================================================= +// List Contacts Tool +// ============================================================================= + +export const ListContactsSchema = Type.Object({ + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), + email: Type.Optional(Type.String({ description: "Filter by email address" })), + phone_number: Type.Optional(Type.String({ description: "Filter by phone number" })), + source: Type.Optional(Type.String({ description: "Filter by source (e.g., 'address_book', 'domain_contact')" })), + group: Type.Optional(Type.String({ description: "Filter by contact group ID" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 25, max: 100)" })), + page_token: Type.Optional(Type.String({ description: "Pagination token for next page" })), +}); + +export type ListContactsParams = Static; + +export async function listContacts(client: NylasClient, params: ListContactsParams) { + const limit = Math.min(params.limit ?? 25, 100); + + const response = await client.listContacts({ + grant: params.grant, + limit, + pageToken: params.page_token, + email: params.email, + phoneNumber: params.phone_number, + source: params.source, + group: params.group, + }); + + const contacts = response.data.map((contact) => { + const name = [contact.givenName, contact.middleName, contact.surname] + .filter(Boolean) + .join(" ") || contact.nickname; + + return { + id: contact.id, + name: name || "(no name)", + company: contact.companyName, + job_title: contact.jobTitle, + emails: contact.emails?.map((e) => ({ email: e.email, type: e.type })), + phone_numbers: contact.phoneNumbers?.map((p) => ({ number: p.number, type: p.type })), + source: contact.source, + }; + }); + + return { + contacts, + has_more: !!response.nextCursor, + next_page_token: response.nextCursor, + count: contacts.length, + }; +} + +// ============================================================================= +// Get Contact Tool +// ============================================================================= + +export const GetContactSchema = Type.Object({ + contact_id: Type.String({ description: "The contact ID" }), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type GetContactParams = Static; + +export async function getContact(client: NylasClient, params: GetContactParams) { + const response = await client.getContact(params.contact_id, params.grant); + const contact = response.data; + + const name = [contact.givenName, contact.middleName, contact.surname] + .filter(Boolean) + .join(" ") || contact.nickname; + + return { + id: contact.id, + name: name || "(no name)", + given_name: contact.givenName, + middle_name: contact.middleName, + surname: contact.surname, + nickname: contact.nickname, + company: contact.companyName, + job_title: contact.jobTitle, + emails: contact.emails?.map((e) => ({ email: e.email, type: e.type })), + phone_numbers: contact.phoneNumbers?.map((p) => ({ number: p.number, type: p.type })), + addresses: contact.physicalAddresses?.map((a) => ({ + street: a.streetAddress, + city: a.city, + state: a.state, + postal_code: a.postalCode, + country: a.country, + type: a.type, + })), + notes: contact.notes, + birthday: contact.birthday, + picture_url: contact.pictureUrl, + source: contact.source, + groups: contact.groups?.map((g) => ({ id: g.id })), + }; +} diff --git a/extensions/nylas/src/tools/email.ts b/extensions/nylas/src/tools/email.ts new file mode 100644 index 000000000..57bd475e4 --- /dev/null +++ b/extensions/nylas/src/tools/email.ts @@ -0,0 +1,273 @@ +import { Type, type Static } from "@sinclair/typebox"; +import type { NylasClient } from "../client.js"; + +// Helper to parse email addresses from string format "name " or just "email" +function parseEmailAddress(input: string): { email: string; name?: string } { + const trimmed = input.trim(); + const match = trimmed.match(/^(.+?)\s*<(.+?)>$/); + if (match) { + return { name: match[1].trim(), email: match[2].trim() }; + } + return { email: trimmed }; +} + +function parseEmailAddresses(input: string | string[] | undefined): Array<{ email: string; name?: string }> | undefined { + if (!input) return undefined; + const items = Array.isArray(input) ? input : input.split(",").map((s) => s.trim()); + return items.filter(Boolean).map(parseEmailAddress); +} + +function formatEmailAddress(addr: { email: string; name?: string }): string { + return addr.name ? `${addr.name} <${addr.email}>` : addr.email; +} + +function formatEmailAddresses(addrs?: Array<{ email: string; name?: string }>): string { + if (!addrs || addrs.length === 0) return ""; + return addrs.map(formatEmailAddress).join(", "); +} + +// ============================================================================= +// List Emails Tool +// ============================================================================= + +export const ListEmailsSchema = Type.Object({ + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), + folder: Type.Optional(Type.String({ description: "Folder name or ID (e.g., 'INBOX', 'SENT', 'DRAFTS')" })), + subject: Type.Optional(Type.String({ description: "Filter by subject (partial match)" })), + from: Type.Optional(Type.String({ description: "Filter by sender email" })), + to: Type.Optional(Type.String({ description: "Filter by recipient email" })), + unread: Type.Optional(Type.Boolean({ description: "Filter by unread status" })), + starred: Type.Optional(Type.Boolean({ description: "Filter by starred status" })), + has_attachment: Type.Optional(Type.Boolean({ description: "Filter by attachment presence" })), + received_after: Type.Optional(Type.String({ description: "ISO 8601 date - emails received after this date" })), + received_before: Type.Optional(Type.String({ description: "ISO 8601 date - emails received before this date" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 10, max: 50)" })), + page_token: Type.Optional(Type.String({ description: "Pagination token for next page" })), +}); + +export type ListEmailsParams = Static; + +export async function listEmails(client: NylasClient, params: ListEmailsParams) { + const limit = Math.min(params.limit ?? 10, 50); + + const response = await client.listMessages({ + grant: params.grant, + limit, + pageToken: params.page_token, + subject: params.subject, + from: params.from, + to: params.to, + in: params.folder, + unread: params.unread, + starred: params.starred, + hasAttachment: params.has_attachment, + receivedAfter: params.received_after ? Math.floor(new Date(params.received_after).getTime() / 1000) : undefined, + receivedBefore: params.received_before ? Math.floor(new Date(params.received_before).getTime() / 1000) : undefined, + }); + + const emails = response.data.map((msg) => ({ + id: msg.id, + thread_id: msg.threadId, + subject: msg.subject ?? "(no subject)", + from: formatEmailAddresses(msg.from), + to: formatEmailAddresses(msg.to), + date: msg.date ? new Date(msg.date * 1000).toISOString() : undefined, + unread: msg.unread, + starred: msg.starred, + snippet: msg.snippet, + has_attachments: (msg.attachments?.length ?? 0) > 0, + folders: msg.folders, + })); + + return { + emails, + has_more: !!response.nextCursor, + next_page_token: response.nextCursor, + count: emails.length, + }; +} + +// ============================================================================= +// Get Email Tool +// ============================================================================= + +export const GetEmailSchema = Type.Object({ + message_id: Type.String({ description: "The email message ID" }), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type GetEmailParams = Static; + +export async function getEmail(client: NylasClient, params: GetEmailParams) { + const response = await client.getMessage(params.message_id, params.grant); + const msg = response.data; + + return { + id: msg.id, + thread_id: msg.threadId, + subject: msg.subject ?? "(no subject)", + from: formatEmailAddresses(msg.from), + to: formatEmailAddresses(msg.to), + cc: formatEmailAddresses(msg.cc), + bcc: formatEmailAddresses(msg.bcc), + reply_to: formatEmailAddresses(msg.replyTo), + date: msg.date ? new Date(msg.date * 1000).toISOString() : undefined, + unread: msg.unread, + starred: msg.starred, + body: msg.body, + folders: msg.folders, + attachments: msg.attachments?.map((a) => ({ + id: a.id, + filename: a.filename, + content_type: a.contentType, + size: a.size, + })), + }; +} + +// ============================================================================= +// Send Email Tool +// ============================================================================= + +export const SendEmailSchema = Type.Object({ + to: Type.String({ description: "Recipient email(s), comma-separated. Format: 'email' or 'Name '" }), + subject: Type.String({ description: "Email subject" }), + body: Type.String({ description: "Email body (HTML supported)" }), + cc: Type.Optional(Type.String({ description: "CC recipients, comma-separated" })), + bcc: Type.Optional(Type.String({ description: "BCC recipients, comma-separated" })), + reply_to_message_id: Type.Optional(Type.String({ description: "Message ID to reply to (for threading)" })), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type SendEmailParams = Static; + +export async function sendEmail(client: NylasClient, params: SendEmailParams) { + const response = await client.sendMessage({ + grant: params.grant, + to: parseEmailAddresses(params.to), + subject: params.subject, + body: params.body, + cc: parseEmailAddresses(params.cc), + bcc: parseEmailAddresses(params.bcc), + replyToMessageId: params.reply_to_message_id, + }); + + return { + success: true, + message_id: response.data.id, + thread_id: response.data.threadId, + }; +} + +// ============================================================================= +// Create Draft Tool +// ============================================================================= + +export const CreateDraftSchema = Type.Object({ + to: Type.Optional(Type.String({ description: "Recipient email(s), comma-separated" })), + subject: Type.Optional(Type.String({ description: "Email subject" })), + body: Type.Optional(Type.String({ description: "Email body (HTML supported)" })), + cc: Type.Optional(Type.String({ description: "CC recipients, comma-separated" })), + bcc: Type.Optional(Type.String({ description: "BCC recipients, comma-separated" })), + reply_to_message_id: Type.Optional(Type.String({ description: "Message ID to reply to (for threading)" })), + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type CreateDraftParams = Static; + +export async function createDraft(client: NylasClient, params: CreateDraftParams) { + const response = await client.createDraft({ + grant: params.grant, + to: parseEmailAddresses(params.to), + subject: params.subject, + body: params.body, + cc: parseEmailAddresses(params.cc), + bcc: parseEmailAddresses(params.bcc), + replyToMessageId: params.reply_to_message_id, + }); + + return { + success: true, + draft_id: response.data.id, + thread_id: response.data.threadId, + }; +} + +// ============================================================================= +// List Threads Tool +// ============================================================================= + +export const ListThreadsSchema = Type.Object({ + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), + folder: Type.Optional(Type.String({ description: "Folder name or ID" })), + subject: Type.Optional(Type.String({ description: "Filter by subject (partial match)" })), + from: Type.Optional(Type.String({ description: "Filter by sender email" })), + to: Type.Optional(Type.String({ description: "Filter by recipient email" })), + unread: Type.Optional(Type.Boolean({ description: "Filter by unread status" })), + starred: Type.Optional(Type.Boolean({ description: "Filter by starred status" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 10, max: 50)" })), + page_token: Type.Optional(Type.String({ description: "Pagination token for next page" })), +}); + +export type ListThreadsParams = Static; + +export async function listThreads(client: NylasClient, params: ListThreadsParams) { + const limit = Math.min(params.limit ?? 10, 50); + + const response = await client.listThreads({ + grant: params.grant, + limit, + pageToken: params.page_token, + subject: params.subject, + from: params.from, + to: params.to, + in: params.folder, + unread: params.unread, + starred: params.starred, + }); + + const threads = response.data.map((thread) => ({ + id: thread.id, + subject: thread.subject ?? "(no subject)", + participants: thread.participants?.map(formatEmailAddress) ?? [], + message_count: thread.messageIds?.length ?? 0, + snippet: thread.snippet, + unread: thread.unread, + starred: thread.starred, + latest_message_date: thread.latestMessageReceivedDate + ? new Date(thread.latestMessageReceivedDate * 1000).toISOString() + : undefined, + folders: thread.folders, + })); + + return { + threads, + has_more: !!response.nextCursor, + next_page_token: response.nextCursor, + count: threads.length, + }; +} + +// ============================================================================= +// List Folders Tool +// ============================================================================= + +export const ListFoldersSchema = Type.Object({ + grant: Type.Optional(Type.String({ description: "Named grant or grant ID (uses default if not specified)" })), +}); + +export type ListFoldersParams = Static; + +export async function listFolders(client: NylasClient, params: ListFoldersParams) { + const response = await client.listFolders(params.grant); + + const folders = response.data.map((folder) => ({ + id: folder.id, + name: folder.name, + system_folder: folder.systemFolder, + unread_count: folder.unreadCount, + total_count: folder.totalCount, + })); + + return { folders }; +} diff --git a/extensions/nylas/src/tools/index.ts b/extensions/nylas/src/tools/index.ts new file mode 100644 index 000000000..ebbffd3d2 --- /dev/null +++ b/extensions/nylas/src/tools/index.ts @@ -0,0 +1,211 @@ +import { Type } from "@sinclair/typebox"; +import type { NylasClient } from "../client.js"; + +// Email tools +import { + ListEmailsSchema, + listEmails, + GetEmailSchema, + getEmail, + SendEmailSchema, + sendEmail, + CreateDraftSchema, + createDraft, + ListThreadsSchema, + listThreads, + ListFoldersSchema, + listFolders, +} from "./email.js"; + +// Calendar tools +import { + ListCalendarsSchema, + listCalendars, + ListEventsSchema, + listEvents, + GetEventSchema, + getEvent, + CreateEventSchema, + createEvent, + UpdateEventSchema, + updateEvent, + DeleteEventSchema, + deleteEvent, + CheckAvailabilitySchema, + checkAvailability, +} from "./calendar.js"; + +// Contact tools +import { + ListContactsSchema, + listContacts, + GetContactSchema, + getContact, +} from "./contacts.js"; + +// ============================================================================= +// Discover Grants Tool (Account Discovery) +// ============================================================================= + +export const DiscoverGrantsSchema = Type.Object({ + provider: Type.Optional(Type.String({ description: "Filter by provider (e.g., 'google', 'microsoft', 'imap')" })), + email: Type.Optional(Type.String({ description: "Filter by email address" })), +}); + +export async function discoverGrants( + client: import("../client.js").NylasClient, + params: { provider?: string; email?: string }, +) { + const response = await client.listGrants({ + provider: params.provider, + email: params.email, + }); + + const grants = response.data.map((grant) => ({ + id: grant.id, + email: grant.email, + provider: grant.provider, + status: grant.grantStatus, + scopes: grant.scope, + })); + + return { + grants, + count: grants.length, + hint: grants.length > 0 + ? "Use the grant 'id' as the 'grant' parameter in other nylas tools to access this account." + : "No authenticated accounts found. User needs to add accounts at dashboard.nylas.com", + }; +} + +export type NylasToolDefinition = { + name: string; + label: string; + description: string; + parameters: unknown; + execute: (client: NylasClient, params: unknown) => Promise; +}; + +export const nylasTools: NylasToolDefinition[] = [ + // Account discovery + { + name: "nylas_discover_grants", + label: "Discover Grants", + description: "Discover all authenticated email accounts (grants) available via this Nylas API key. Use this to find grant IDs for multi-account access.", + parameters: DiscoverGrantsSchema, + execute: (client, params) => discoverGrants(client, params as Parameters[1]), + }, + + // Email tools + { + name: "nylas_list_emails", + label: "List Emails", + description: "List and search emails with filters for folder, sender, subject, date range, etc.", + parameters: ListEmailsSchema, + execute: (client, params) => listEmails(client, params as Parameters[1]), + }, + { + name: "nylas_get_email", + label: "Get Email", + description: "Get full content of an email by ID including body, attachments, and metadata.", + parameters: GetEmailSchema, + execute: (client, params) => getEmail(client, params as Parameters[1]), + }, + { + name: "nylas_send_email", + label: "Send Email", + description: "Send an email to recipients with subject, body, and optional CC/BCC.", + parameters: SendEmailSchema, + execute: (client, params) => sendEmail(client, params as Parameters[1]), + }, + { + name: "nylas_create_draft", + label: "Create Draft", + description: "Create an email draft to edit and send later.", + parameters: CreateDraftSchema, + execute: (client, params) => createDraft(client, params as Parameters[1]), + }, + { + name: "nylas_list_threads", + label: "List Threads", + description: "List email threads (conversations) with filters.", + parameters: ListThreadsSchema, + execute: (client, params) => listThreads(client, params as Parameters[1]), + }, + { + name: "nylas_list_folders", + label: "List Folders", + description: "List email folders/labels (INBOX, SENT, DRAFTS, custom folders, etc.).", + parameters: ListFoldersSchema, + execute: (client, params) => listFolders(client, params as Parameters[1]), + }, + + // Calendar tools + { + name: "nylas_list_calendars", + label: "List Calendars", + description: "List available calendars for the account.", + parameters: ListCalendarsSchema, + execute: (client, params) => listCalendars(client, params as Parameters[1]), + }, + { + name: "nylas_list_events", + label: "List Events", + description: "List calendar events with optional date range and title filters.", + parameters: ListEventsSchema, + execute: (client, params) => listEvents(client, params as Parameters[1]), + }, + { + name: "nylas_get_event", + label: "Get Event", + description: "Get full details of a calendar event by ID.", + parameters: GetEventSchema, + execute: (client, params) => getEvent(client, params as Parameters[1]), + }, + { + name: "nylas_create_event", + label: "Create Event", + description: "Create a new calendar event with title, time, location, and participants.", + parameters: CreateEventSchema, + execute: (client, params) => createEvent(client, params as Parameters[1]), + }, + { + name: "nylas_update_event", + label: "Update Event", + description: "Update an existing calendar event.", + parameters: UpdateEventSchema, + execute: (client, params) => updateEvent(client, params as Parameters[1]), + }, + { + name: "nylas_delete_event", + label: "Delete Event", + description: "Delete a calendar event.", + parameters: DeleteEventSchema, + execute: (client, params) => deleteEvent(client, params as Parameters[1]), + }, + { + name: "nylas_check_availability", + label: "Check Availability", + description: "Check availability for a group of participants within a time window.", + parameters: CheckAvailabilitySchema, + execute: (client, params) => checkAvailability(client, params as Parameters[1]), + }, + + // Contact tools + { + name: "nylas_list_contacts", + label: "List Contacts", + description: "List contacts with optional filters for email, phone, or group.", + parameters: ListContactsSchema, + execute: (client, params) => listContacts(client, params as Parameters[1]), + }, + { + name: "nylas_get_contact", + label: "Get Contact", + description: "Get full details of a contact by ID.", + parameters: GetContactSchema, + execute: (client, params) => getContact(client, params as Parameters[1]), + }, +]; + +export { Type };