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
This commit is contained in:
parent
fe1f2d971a
commit
4c74b1134d
188
extensions/nylas/README.md
Normal file
188
extensions/nylas/README.md
Normal file
@ -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: "<p>This is the email body.</p>"
|
||||
})
|
||||
```
|
||||
|
||||
**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)
|
||||
56
extensions/nylas/clawdbot.plugin.json
Normal file
56
extensions/nylas/clawdbot.plugin.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
477
extensions/nylas/index.live.test.ts
Normal file
477
extensions/nylas/index.live.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
2037
extensions/nylas/index.test.ts
Normal file
2037
extensions/nylas/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
213
extensions/nylas/index.ts
Normal file
213
extensions/nylas/index.ts
Normal file
@ -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<NylasClient["createEvent"]>[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;
|
||||
16
extensions/nylas/package.json
Normal file
16
extensions/nylas/package.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
362
extensions/nylas/src/cli.ts
Normal file
362
extensions/nylas/src/cli.ts
Normal file
@ -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>", "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)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
534
extensions/nylas/src/client.ts
Normal file
534
extensions/nylas/src/client.ts
Normal file
@ -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<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
const result: Partial<T> = {};
|
||||
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<string, string>;
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
extensions/nylas/src/config.ts
Normal file
46
extensions/nylas/src/config.ts
Normal file
@ -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<typeof NylasConfigSchema>;
|
||||
|
||||
export function parseNylasConfig(value: unknown): NylasConfig {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
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<string, string>) : {},
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
339
extensions/nylas/src/tools/calendar.ts
Normal file
339
extensions/nylas/src/tools/calendar.ts
Normal file
@ -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<typeof ListCalendarsSchema>;
|
||||
|
||||
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<typeof ListEventsSchema>;
|
||||
|
||||
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<typeof GetEventSchema>;
|
||||
|
||||
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<typeof CreateEventSchema>;
|
||||
|
||||
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<typeof UpdateEventSchema>;
|
||||
|
||||
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<typeof DeleteEventSchema>;
|
||||
|
||||
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<typeof CheckAvailabilitySchema>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
101
extensions/nylas/src/tools/contacts.ts
Normal file
101
extensions/nylas/src/tools/contacts.ts
Normal file
@ -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<typeof ListContactsSchema>;
|
||||
|
||||
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<typeof GetContactSchema>;
|
||||
|
||||
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 })),
|
||||
};
|
||||
}
|
||||
273
extensions/nylas/src/tools/email.ts
Normal file
273
extensions/nylas/src/tools/email.ts
Normal file
@ -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 <email>" 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<typeof ListEmailsSchema>;
|
||||
|
||||
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<typeof GetEmailSchema>;
|
||||
|
||||
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 <email>'" }),
|
||||
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<typeof SendEmailSchema>;
|
||||
|
||||
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<typeof CreateDraftSchema>;
|
||||
|
||||
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<typeof ListThreadsSchema>;
|
||||
|
||||
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<typeof ListFoldersSchema>;
|
||||
|
||||
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 };
|
||||
}
|
||||
211
extensions/nylas/src/tools/index.ts
Normal file
211
extensions/nylas/src/tools/index.ts
Normal file
@ -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<unknown>;
|
||||
};
|
||||
|
||||
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<typeof discoverGrants>[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<typeof listEmails>[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<typeof getEmail>[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<typeof sendEmail>[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<typeof createDraft>[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<typeof listThreads>[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<typeof listFolders>[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<typeof listCalendars>[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<typeof listEvents>[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<typeof getEvent>[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<typeof createEvent>[1]),
|
||||
},
|
||||
{
|
||||
name: "nylas_update_event",
|
||||
label: "Update Event",
|
||||
description: "Update an existing calendar event.",
|
||||
parameters: UpdateEventSchema,
|
||||
execute: (client, params) => updateEvent(client, params as Parameters<typeof updateEvent>[1]),
|
||||
},
|
||||
{
|
||||
name: "nylas_delete_event",
|
||||
label: "Delete Event",
|
||||
description: "Delete a calendar event.",
|
||||
parameters: DeleteEventSchema,
|
||||
execute: (client, params) => deleteEvent(client, params as Parameters<typeof deleteEvent>[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<typeof checkAvailability>[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<typeof listContacts>[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<typeof getContact>[1]),
|
||||
},
|
||||
];
|
||||
|
||||
export { Type };
|
||||
Loading…
Reference in New Issue
Block a user