Skip to main content
Glama
jmap-client.ts24.3 kB
import { FastmailAuth } from './auth.js'; export interface JmapSession { apiUrl: string; accountId: string; capabilities: Record<string, any>; downloadUrl?: string; uploadUrl?: string; } export interface JmapRequest { using: string[]; methodCalls: [string, any, string][]; } export interface JmapResponse { methodResponses: Array<[string, any, string]>; sessionState: string; } export class JmapClient { private auth: FastmailAuth; private session: JmapSession | null = null; constructor(auth: FastmailAuth) { this.auth = auth; } async getSession(): Promise<JmapSession> { if (this.session) { return this.session; } const response = await fetch(this.auth.getSessionUrl(), { method: 'GET', headers: this.auth.getAuthHeaders() }); if (!response.ok) { throw new Error(`Failed to get session: ${response.statusText}`); } const sessionData = await response.json() as any; this.session = { apiUrl: sessionData.apiUrl, accountId: Object.keys(sessionData.accounts)[0], capabilities: sessionData.capabilities, downloadUrl: sessionData.downloadUrl, uploadUrl: sessionData.uploadUrl }; return this.session; } async getUserEmail(): Promise<string> { try { const identity = await this.getDefaultIdentity(); return identity?.email || 'user@example.com'; } catch (error) { // Fallback if Identity/get is not available return 'user@example.com'; } } async makeRequest(request: JmapRequest): Promise<JmapResponse> { const session = await this.getSession(); const response = await fetch(session.apiUrl, { method: 'POST', headers: this.auth.getAuthHeaders(), body: JSON.stringify(request) }); if (!response.ok) { throw new Error(`JMAP request failed: ${response.statusText}`); } return await response.json() as JmapResponse; } async getMailboxes(): Promise<any[]> { const session = await this.getSession(); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Mailbox/get', { accountId: session.accountId }, 'mailboxes'] ] }; const response = await this.makeRequest(request); return response.methodResponses[0][1].list; } async getEmails(mailboxId?: string, limit: number = 20): Promise<any[]> { const session = await this.getSession(); const filter = mailboxId ? { inMailbox: mailboxId } : {}; const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/query', { accountId: session.accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit }, 'query'], ['Email/get', { accountId: session.accountId, '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'from', 'to', 'receivedAt', 'preview', 'hasAttachment'] }, 'emails'] ] }; const response = await this.makeRequest(request); return response.methodResponses[1][1].list; } async getEmailById(id: string): Promise<any> { const session = await this.getSession(); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/get', { accountId: session.accountId, ids: [id], properties: ['id', 'subject', 'from', 'to', 'cc', 'bcc', 'receivedAt', 'textBody', 'htmlBody', 'attachments', 'bodyValues'], bodyProperties: ['partId', 'blobId', 'type', 'size'], fetchTextBodyValues: true, fetchHTMLBodyValues: true, }, 'email'] ] }; const response = await this.makeRequest(request); const result = response.methodResponses[0][1]; if (result.notFound && result.notFound.includes(id)) { throw new Error(`Email with ID '${id}' not found`); } const email = result.list[0]; if (!email) { throw new Error(`Email with ID '${id}' not found or not accessible`); } return email; } async getIdentities(): Promise<any[]> { const session = await this.getSession(); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'], methodCalls: [ ['Identity/get', { accountId: session.accountId }, 'identities'] ] }; const response = await this.makeRequest(request); return response.methodResponses[0][1].list; } async getDefaultIdentity(): Promise<any> { const identities = await this.getIdentities(); // Find the default identity (usually the one that can't be deleted) return identities.find((id: any) => id.mayDelete === false) || identities[0]; } async sendEmail(email: { to: string[]; cc?: string[]; bcc?: string[]; subject: string; textBody?: string; htmlBody?: string; from?: string; mailboxId?: string; }): Promise<string> { const session = await this.getSession(); // Get all identities to validate from address const identities = await this.getIdentities(); if (!identities || identities.length === 0) { throw new Error('No sending identities found'); } // Determine which identity to use let selectedIdentity; if (email.from) { // Validate that the from address matches an available identity selectedIdentity = identities.find(id => id.email.toLowerCase() === email.from?.toLowerCase() ); if (!selectedIdentity) { throw new Error('From address is not verified for sending. Choose one of your verified identities.'); } } else { // Use default identity selectedIdentity = identities.find(id => id.mayDelete === false) || identities[0]; } const fromEmail = selectedIdentity.email; // Get the mailbox IDs we need const mailboxes = await this.getMailboxes(); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts') || mailboxes.find(mb => mb.name.toLowerCase().includes('draft')); const sentMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes.find(mb => mb.name.toLowerCase().includes('sent')); if (!draftsMailbox) { throw new Error('Could not find Drafts mailbox to save email'); } if (!sentMailbox) { throw new Error('Could not find Sent mailbox to move email after sending'); } // Use provided mailboxId or default to drafts for initial creation const initialMailboxId = email.mailboxId || draftsMailbox.id; // Ensure we have at least one body type if (!email.textBody && !email.htmlBody) { throw new Error('Either textBody or htmlBody must be provided'); } const initialMailboxIds: Record<string, boolean> = {}; initialMailboxIds[initialMailboxId] = true; const sentMailboxIds: Record<string, boolean> = {}; sentMailboxIds[sentMailbox.id] = true; const emailObject = { mailboxIds: initialMailboxIds, keywords: { $draft: true }, from: [{ email: fromEmail }], to: email.to.map(addr => ({ email: addr })), cc: email.cc?.map(addr => ({ email: addr })) || [], bcc: email.bcc?.map(addr => ({ email: addr })) || [], subject: email.subject, textBody: email.textBody ? [{ partId: 'text', type: 'text/plain' }] : undefined, htmlBody: email.htmlBody ? [{ partId: 'html', type: 'text/html' }] : undefined, bodyValues: { ...(email.textBody && { text: { value: email.textBody } }), ...(email.htmlBody && { html: { value: email.htmlBody } }) } }; const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission'], methodCalls: [ ['Email/set', { accountId: session.accountId, create: { draft: emailObject } }, 'createEmail'], ['EmailSubmission/set', { accountId: session.accountId, create: { submission: { emailId: '#draft', identityId: selectedIdentity.id, envelope: { mailFrom: { email: fromEmail }, rcptTo: email.to.map(addr => ({ email: addr })) } } }, onSuccessUpdateEmail: { '#submission': { mailboxIds: sentMailboxIds, keywords: { $seen: true } } } }, 'submitEmail'] ] }; const response = await this.makeRequest(request); // Check if email creation was successful const emailResult = response.methodResponses[0][1]; if (emailResult.notCreated && emailResult.notCreated.draft) { throw new Error('Failed to create email. Please check inputs and try again.'); } // Check if email submission was successful const submissionResult = response.methodResponses[1][1]; if (submissionResult.notCreated && submissionResult.notCreated.submission) { throw new Error('Failed to submit email. Please try again later.'); } return submissionResult.created?.submission?.id || 'unknown'; } async getRecentEmails(limit: number = 10, mailboxName: string = 'inbox'): Promise<any[]> { const session = await this.getSession(); // Find the specified mailbox (default to inbox) const mailboxes = await this.getMailboxes(); const targetMailbox = mailboxes.find(mb => mb.role === mailboxName.toLowerCase() || mb.name.toLowerCase().includes(mailboxName.toLowerCase()) ); if (!targetMailbox) { throw new Error(`Could not find mailbox: ${mailboxName}`); } const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/query', { accountId: session.accountId, filter: { inMailbox: targetMailbox.id }, sort: [{ property: 'receivedAt', isAscending: false }], limit: Math.min(limit, 50) }, 'query'], ['Email/get', { accountId: session.accountId, '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'from', 'to', 'receivedAt', 'preview', 'hasAttachment', 'keywords'] }, 'emails'] ] }; const response = await this.makeRequest(request); return response.methodResponses[1][1].list; } async markEmailRead(emailId: string, read: boolean = true): Promise<void> { const session = await this.getSession(); const keywords = read ? { $seen: true } : {}; const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/set', { accountId: session.accountId, update: { [emailId]: { keywords } } }, 'updateEmail'] ] }; const response = await this.makeRequest(request); const result = response.methodResponses[0][1]; if (result.notUpdated && result.notUpdated[emailId]) { throw new Error(`Failed to mark email as ${read ? 'read' : 'unread'}.`); } } async deleteEmail(emailId: string): Promise<void> { const session = await this.getSession(); // Find the trash mailbox const mailboxes = await this.getMailboxes(); const trashMailbox = mailboxes.find(mb => mb.role === 'trash') || mailboxes.find(mb => mb.name.toLowerCase().includes('trash')); if (!trashMailbox) { throw new Error('Could not find Trash mailbox'); } const trashMailboxIds: Record<string, boolean> = {}; trashMailboxIds[trashMailbox.id] = true; const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/set', { accountId: session.accountId, update: { [emailId]: { mailboxIds: trashMailboxIds } } }, 'moveToTrash'] ] }; const response = await this.makeRequest(request); const result = response.methodResponses[0][1]; if (result.notUpdated && result.notUpdated[emailId]) { throw new Error('Failed to delete email.'); } } async moveEmail(emailId: string, targetMailboxId: string): Promise<void> { const session = await this.getSession(); const targetMailboxIds: Record<string, boolean> = {}; targetMailboxIds[targetMailboxId] = true; const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/set', { accountId: session.accountId, update: { [emailId]: { mailboxIds: targetMailboxIds } } }, 'moveEmail'] ] }; const response = await this.makeRequest(request); const result = response.methodResponses[0][1]; if (result.notUpdated && result.notUpdated[emailId]) { throw new Error('Failed to move email.'); } } async getEmailAttachments(emailId: string): Promise<any[]> { const session = await this.getSession(); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/get', { accountId: session.accountId, ids: [emailId], properties: ['attachments'] }, 'getAttachments'] ] }; const response = await this.makeRequest(request); const email = response.methodResponses[0][1].list[0]; return email?.attachments || []; } async downloadAttachment(emailId: string, attachmentId: string): Promise<string> { const session = await this.getSession(); // Get the email with full attachment details const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/get', { accountId: session.accountId, ids: [emailId], properties: ['attachments', 'bodyValues'], bodyProperties: ['partId', 'blobId', 'size', 'name', 'type'] }, 'getEmail'] ] }; const response = await this.makeRequest(request); const email = response.methodResponses[0][1].list[0]; if (!email) { throw new Error('Email not found'); } // Find attachment by partId or by index let attachment = email.attachments?.find((att: any) => att.partId === attachmentId || att.blobId === attachmentId ); // If not found, try by array index if (!attachment && !isNaN(parseInt(attachmentId))) { const index = parseInt(attachmentId); attachment = email.attachments?.[index]; } if (!attachment) { throw new Error('Attachment not found.'); } // Get the download URL from session const downloadUrl = session.downloadUrl; if (!downloadUrl) { throw new Error('Download capability not available in session'); } // Build download URL const url = downloadUrl .replace('{accountId}', session.accountId) .replace('{blobId}', attachment.blobId) .replace('{type}', encodeURIComponent(attachment.type || 'application/octet-stream')) .replace('{name}', encodeURIComponent(attachment.name || 'attachment')); return url; } async advancedSearch(filters: { query?: string; from?: string; to?: string; subject?: string; hasAttachment?: boolean; isUnread?: boolean; mailboxId?: string; after?: string; before?: string; limit?: number; }): Promise<any[]> { const session = await this.getSession(); // Build JMAP filter object const filter: any = {}; if (filters.query) filter.text = filters.query; if (filters.from) filter.from = filters.from; if (filters.to) filter.to = filters.to; if (filters.subject) filter.subject = filters.subject; if (filters.hasAttachment !== undefined) filter.hasAttachment = filters.hasAttachment; if (filters.isUnread !== undefined) filter.hasKeyword = filters.isUnread ? undefined : '$seen'; if (filters.mailboxId) filter.inMailbox = filters.mailboxId; if (filters.after) filter.after = filters.after; if (filters.before) filter.before = filters.before; // If unread filter is specifically true, we need to check for absence of $seen if (filters.isUnread === true) { filter.notKeyword = '$seen'; delete filter.hasKeyword; } const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/query', { accountId: session.accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: Math.min(filters.limit || 50, 100) }, 'query'], ['Email/get', { accountId: session.accountId, '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'from', 'to', 'cc', 'receivedAt', 'preview', 'hasAttachment', 'keywords', 'threadId'] }, 'emails'] ] }; const response = await this.makeRequest(request); return response.methodResponses[1][1].list; } async getThread(threadId: string): Promise<any[]> { const session = await this.getSession(); // First, check if threadId is actually an email ID and resolve the thread let actualThreadId = threadId; // Try to get the email first to see if we need to resolve thread ID try { const emailRequest: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/get', { accountId: session.accountId, ids: [threadId], properties: ['threadId'] }, 'checkEmail'] ] }; const emailResponse = await this.makeRequest(emailRequest); const email = emailResponse.methodResponses[0][1].list[0]; if (email && email.threadId) { actualThreadId = email.threadId; } } catch (error) { // If email lookup fails, assume threadId is correct } // Use Thread/get with the resolved thread ID const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Thread/get', { accountId: session.accountId, ids: [actualThreadId] }, 'getThread'], ['Email/get', { accountId: session.accountId, '#ids': { resultOf: 'getThread', name: 'Thread/get', path: '/list/*/emailIds' }, properties: ['id', 'subject', 'from', 'to', 'cc', 'receivedAt', 'preview', 'hasAttachment', 'keywords', 'threadId'] }, 'emails'] ] }; const response = await this.makeRequest(request); const threadResult = response.methodResponses[0][1]; // Check if thread was found if (threadResult.notFound && threadResult.notFound.includes(actualThreadId)) { throw new Error(`Thread with ID '${actualThreadId}' not found`); } return response.methodResponses[1][1].list; } async getMailboxStats(mailboxId?: string): Promise<any> { const session = await this.getSession(); if (mailboxId) { // Get stats for specific mailbox const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Mailbox/get', { accountId: session.accountId, ids: [mailboxId], properties: ['id', 'name', 'role', 'totalEmails', 'unreadEmails', 'totalThreads', 'unreadThreads'] }, 'mailbox'] ] }; const response = await this.makeRequest(request); return response.methodResponses[0][1].list[0]; } else { // Get stats for all mailboxes const mailboxes = await this.getMailboxes(); return mailboxes.map(mb => ({ id: mb.id, name: mb.name, role: mb.role, totalEmails: mb.totalEmails || 0, unreadEmails: mb.unreadEmails || 0, totalThreads: mb.totalThreads || 0, unreadThreads: mb.unreadThreads || 0 })); } } async getAccountSummary(): Promise<any> { const session = await this.getSession(); const mailboxes = await this.getMailboxes(); const identities = await this.getIdentities(); // Calculate totals const totals = mailboxes.reduce((acc, mb) => ({ totalEmails: acc.totalEmails + (mb.totalEmails || 0), unreadEmails: acc.unreadEmails + (mb.unreadEmails || 0), totalThreads: acc.totalThreads + (mb.totalThreads || 0), unreadThreads: acc.unreadThreads + (mb.unreadThreads || 0) }), { totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }); return { accountId: session.accountId, mailboxCount: mailboxes.length, identityCount: identities.length, ...totals, mailboxes: mailboxes.map(mb => ({ id: mb.id, name: mb.name, role: mb.role, totalEmails: mb.totalEmails || 0, unreadEmails: mb.unreadEmails || 0 })) }; } async bulkMarkRead(emailIds: string[], read: boolean = true): Promise<void> { const session = await this.getSession(); const keywords = read ? { $seen: true } : {}; const updates: Record<string, any> = {}; emailIds.forEach(id => { updates[id] = { keywords }; }); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/set', { accountId: session.accountId, update: updates }, 'bulkUpdate'] ] }; const response = await this.makeRequest(request); const result = response.methodResponses[0][1]; if (result.notUpdated && Object.keys(result.notUpdated).length > 0) { throw new Error('Failed to update some emails.'); } } async bulkMove(emailIds: string[], targetMailboxId: string): Promise<void> { const session = await this.getSession(); const targetMailboxIds: Record<string, boolean> = {}; targetMailboxIds[targetMailboxId] = true; const updates: Record<string, any> = {}; emailIds.forEach(id => { updates[id] = { mailboxIds: targetMailboxIds }; }); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/set', { accountId: session.accountId, update: updates }, 'bulkMove'] ] }; const response = await this.makeRequest(request); const result = response.methodResponses[0][1]; if (result.notUpdated && Object.keys(result.notUpdated).length > 0) { throw new Error('Failed to move some emails.'); } } async bulkDelete(emailIds: string[]): Promise<void> { const session = await this.getSession(); // Find the trash mailbox const mailboxes = await this.getMailboxes(); const trashMailbox = mailboxes.find(mb => mb.role === 'trash') || mailboxes.find(mb => mb.name.toLowerCase().includes('trash')); if (!trashMailbox) { throw new Error('Could not find Trash mailbox'); } const trashMailboxIds: Record<string, boolean> = {}; trashMailboxIds[trashMailbox.id] = true; const updates: Record<string, any> = {}; emailIds.forEach(id => { updates[id] = { mailboxIds: trashMailboxIds }; }); const request: JmapRequest = { using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Email/set', { accountId: session.accountId, update: updates }, 'bulkDelete'] ] }; const response = await this.makeRequest(request); const result = response.methodResponses[0][1]; if (result.notUpdated && Object.keys(result.notUpdated).length > 0) { throw new Error('Failed to delete some emails.'); } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/MadLlama25/fastmail-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server