Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 20 additions & 16 deletions src/contacts-calendar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JmapClient, JmapRequest } from './jmap-client.js';
import { JmapClient, JmapRequest, QueryResult } from './jmap-client.js';

export class ContactsCalendarClient extends JmapClient {

Expand All @@ -12,22 +12,23 @@ export class ContactsCalendarClient extends JmapClient {
return !!session.capabilities['urn:ietf:params:jmap:calendars'];
}

async getContacts(limit: number = 50): Promise<any[]> {
async getContacts(limit: number = 50): Promise<QueryResult> {
// Check permissions first
const hasPermission = await this.checkContactsPermission();
if (!hasPermission) {
throw new Error('Contacts access not available. This account may not have JMAP contacts permissions enabled. Please check your Fastmail account settings or contact support to enable contacts API access.');
}

const session = await this.getSession();

// Try CardDAV namespace first, then Fastmail specific
const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:contacts'],
methodCalls: [
['ContactCard/query', {
accountId: session.accountId,
limit
limit,
calculateTotal: true
}, 'query'],
['ContactCard/get', {
accountId: session.accountId,
Expand All @@ -39,7 +40,7 @@ export class ContactsCalendarClient extends JmapClient {

try {
const response = await this.makeRequest(request);
return this.getListResult(response, 1);
return this.getQueryResult(response, 0, 1);
} catch (error) {
// Fallback: try to get contacts using AddressBook methods
const fallbackRequest: JmapRequest = {
Expand All @@ -50,10 +51,11 @@ export class ContactsCalendarClient extends JmapClient {
}, 'addressbooks']
]
};

try {
const fallbackResponse = await this.makeRequest(fallbackRequest);
return this.getListResult(fallbackResponse, 0);
const items = this.getListResult(fallbackResponse, 0);
return { items };
} catch (fallbackError) {
throw new Error(`Contacts not supported or accessible: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling contacts API access in Fastmail settings.`);
}
Expand Down Expand Up @@ -87,22 +89,23 @@ export class ContactsCalendarClient extends JmapClient {
}
}

async searchContacts(query: string, limit: number = 20): Promise<any[]> {
async searchContacts(query: string, limit: number = 20): Promise<QueryResult> {
// Check permissions first
const hasPermission = await this.checkContactsPermission();
if (!hasPermission) {
throw new Error('Contacts access not available. This account may not have JMAP contacts permissions enabled. Please check your Fastmail account settings or contact support to enable contacts API access.');
}

const session = await this.getSession();

const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:contacts'],
methodCalls: [
['ContactCard/query', {
accountId: session.accountId,
filter: { text: query },
limit
limit,
calculateTotal: true
}, 'query'],
['ContactCard/get', {
accountId: session.accountId,
Expand All @@ -114,7 +117,7 @@ export class ContactsCalendarClient extends JmapClient {

try {
const response = await this.makeRequest(request);
return this.getListResult(response, 1);
return this.getQueryResult(response, 0, 1);
} catch (error) {
throw new Error(`Contact search not supported: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling contacts API access in Fastmail settings.`);
}
Expand Down Expand Up @@ -147,25 +150,26 @@ export class ContactsCalendarClient extends JmapClient {
}
}

async getCalendarEvents(calendarId?: string, limit: number = 50): Promise<any[]> {
async getCalendarEvents(calendarId?: string, limit: number = 50): Promise<QueryResult> {
// Check permissions first
const hasPermission = await this.checkCalendarsPermission();
if (!hasPermission) {
throw new Error('Calendar access not available. This account may not have JMAP calendar permissions enabled. Please check your Fastmail account settings or contact support to enable calendar API access.');
}

const session = await this.getSession();

const filter = calendarId ? { inCalendar: calendarId } : {};

const request: JmapRequest = {
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:calendars'],
methodCalls: [
['CalendarEvent/query', {
accountId: session.accountId,
filter,
sort: [{ property: 'start', isAscending: true }],
limit
limit,
calculateTotal: true
}, 'query'],
['CalendarEvent/get', {
accountId: session.accountId,
Expand All @@ -177,7 +181,7 @@ export class ContactsCalendarClient extends JmapClient {

try {
const response = await this.makeRequest(request);
return this.getListResult(response, 1);
return this.getQueryResult(response, 0, 1);
} catch (error) {
throw new Error(`Calendar events access not supported: ${error instanceof Error ? error.message : String(error)}. Try checking account permissions or enabling calendar API access in Fastmail settings.`);
}
Expand Down
50 changes: 31 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { FastmailAuth, FastmailConfig } from './auth.js';
import { JmapClient } from './jmap-client.js';
import { JmapClient, QueryResult } from './jmap-client.js';
import { ContactsCalendarClient } from './contacts-calendar.js';
import { CalDAVCalendarClient } from './caldav-client.js';

Expand Down Expand Up @@ -978,6 +978,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;

function formatQueryResult(result: QueryResult): string {
const { items, total } = result;
const summary = total != null && total > items.length
? `Showing ${items.length} of ${total} results.`
: total != null
? `${total} results.`
: `${items.length} results.`;
return `${summary}\n${JSON.stringify(items, null, 2)}`;
}

try {

const client = initializeClient();
Expand All @@ -997,13 +1007,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {

case 'list_emails': {
const { mailboxId, limit, ascending } = args as any;
const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 50);
const emails = await client.getEmails(mailboxId, validLimit, !!ascending);
const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const result = await client.getEmails(mailboxId, validLimit, !!ascending);
return {
content: [
{
type: 'text',
text: JSON.stringify(emails, null, 2),
text: formatQueryResult(result),
},
],
};
Expand Down Expand Up @@ -1225,12 +1235,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (!query) {
throw new McpError(ErrorCode.InvalidParams, 'query is required');
}
const emails = await client.searchEmails(query, limit, !!ascending);
const validLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const result = await client.searchEmails(query, validLimit, !!ascending);
return {
content: [
{
type: 'text',
text: JSON.stringify(emails, null, 2),
text: formatQueryResult(result),
},
],
};
Expand All @@ -1239,12 +1250,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case 'list_contacts': {
const { limit = 50 } = args as any;
const contactsClient = initializeContactsCalendarClient();
const contacts = await contactsClient.getContacts(limit);
const result = await contactsClient.getContacts(limit);
return {
content: [
{
type: 'text',
text: JSON.stringify(contacts, null, 2),
text: formatQueryResult(result),
},
],
};
Expand Down Expand Up @@ -1273,12 +1284,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
throw new McpError(ErrorCode.InvalidParams, 'query is required');
}
const contactsClient = initializeContactsCalendarClient();
const contacts = await contactsClient.searchContacts(query, limit);
const result = await contactsClient.searchContacts(query, limit);
return {
content: [
{
type: 'text',
text: JSON.stringify(contacts, null, 2),
text: formatQueryResult(result),
},
],
};
Expand All @@ -1304,8 +1315,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { calendarId, limit = 50, startDate, endDate } = args as any;
try {
const contactsClient = initializeContactsCalendarClient();
const events = await contactsClient.getCalendarEvents(calendarId, limit);
return { content: [{ type: 'text', text: JSON.stringify(events, null, 2) }] };
const result = await contactsClient.getCalendarEvents(calendarId, limit);
return { content: [{ type: 'text', text: formatQueryResult(result) }] };
} catch {
const davClient = initializeCalDAVClient();
if (!davClient) {
Expand Down Expand Up @@ -1375,12 +1386,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case 'get_recent_emails': {
const { limit = 10, mailboxName = 'inbox', ascending } = args as any;
const client = initializeClient();
const emails = await client.getRecentEmails(limit, mailboxName, !!ascending);
const result = await client.getRecentEmails(limit, mailboxName, !!ascending);
return {
content: [
{
type: 'text',
text: JSON.stringify(emails, null, 2),
text: formatQueryResult(result),
},
],
};
Expand Down Expand Up @@ -1555,14 +1566,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case 'advanced_search': {
const { query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit, ascending } = args as any;
const client = initializeClient();
const emails = await client.advancedSearch({
query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit, ascending
const validLimit = Math.min(Math.max(Number(limit) || 50, 1), 100);
const result = await client.advancedSearch({
query, from, to, subject, hasAttachment, isUnread, isPinned, mailboxId, after, before, limit: validLimit, ascending
});
return {
content: [
{
type: 'text',
text: JSON.stringify(emails, null, 2),
text: formatQueryResult(result),
},
],
};
Expand Down Expand Up @@ -1798,8 +1810,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {

// Get some recent emails to test with
const testLimit = Math.min(Math.max(limit, 1), 10);
const emails = await client.getRecentEmails(testLimit, 'inbox');
const { items: emails } = await client.getRecentEmails(testLimit, 'inbox');

if (emails.length === 0) {
return {
content: [
Expand Down
18 changes: 9 additions & 9 deletions src/jmap-client-extra.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ describe('getRecentEmails', () => {
],
});

const emails = await client.getRecentEmails(10, 'inbox');
assert.equal(emails.length, 2);
assert.equal(emails[0].subject, 'First');
const result = await client.getRecentEmails(10, 'inbox');
assert.equal(result.items.length, 2);
assert.equal(result.items[0].subject, 'First');
});

it('throws when mailbox is not found', async () => {
Expand All @@ -113,8 +113,8 @@ describe('getRecentEmails', () => {
],
});

const emails = await client.getRecentEmails(5, 'inbox');
assert.deepEqual(emails, []);
const result = await client.getRecentEmails(5, 'inbox');
assert.deepEqual(result.items, []);
});
});

Expand All @@ -135,8 +135,8 @@ describe('getEmails', () => {
],
}));

const emails = await client.getEmails('mb-inbox', 5);
assert.equal(emails.length, 1);
const result = await client.getEmails('mb-inbox', 5);
assert.equal(result.items.length, 1);

const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter;
assert.equal(filter.inMailbox, 'mb-inbox');
Expand All @@ -150,8 +150,8 @@ describe('getEmails', () => {
],
}));

const emails = await client.getEmails(undefined, 10);
assert.equal(emails.length, 1);
const result = await client.getEmails(undefined, 10);
assert.equal(result.items.length, 1);

const filter = makeReq.mock.calls[0].arguments[0].methodCalls[0][1].filter;
assert.deepEqual(filter, {});
Expand Down
6 changes: 3 additions & 3 deletions src/jmap-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,8 +504,8 @@ describe('searchEmails', () => {
],
});
const results = await client.searchEmails('test', 10);
assert.equal(results.length, 1);
assert.equal(results[0].subject, 'Test');
assert.equal(results.items.length, 1);
assert.equal(results.items[0].subject, 'Test');
});

it('returns empty array when no results', async () => {
Expand All @@ -516,7 +516,7 @@ describe('searchEmails', () => {
],
});
const results = await client.searchEmails('nonexistent');
assert.deepEqual(results, []);
assert.deepEqual(results.items, []);
});

it('throws on JMAP error in query', async () => {
Expand Down
Loading