Skip to main content
Glama
fastmail-client.jsβ€’29.6 kB
import fetch from 'node-fetch'; export class FastMailClient { constructor(apiToken, email, sendAs, aliasDomain, jmapUrl) { this.apiToken = apiToken; this.email = email; this.sendAs = sendAs; this.aliasDomain = aliasDomain; this.jmapUrl = jmapUrl; this.session = null; this.accountId = null; this.emailSendCache = new Map(); // Track recent email sends to prevent duplicates this.duplicatePreventionWindow = 5 * 60 * 1000; // 5 minutes in milliseconds } async authenticate() { const response = await fetch(this.jmapUrl, { method: 'GET', headers: { 'Authorization': `Bearer ${this.apiToken}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`Authentication failed: ${response.status} ${response.statusText}`); } this.session = await response.json(); this.accountId = this.session.primaryAccounts['urn:ietf:params:jmap:mail']; if (!this.accountId) { throw new Error('No mail account found in session'); } return this.session; } async makeJmapRequest(methodCalls) { if (!this.session) { await this.authenticate(); } const requestBody = { using: [ 'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission' ], methodCalls: methodCalls }; const response = await fetch(this.session.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`JMAP request failed: ${response.status} ${response.statusText}`); } return await response.json(); } async getMailboxes() { const methodCalls = [ ['Mailbox/get', { accountId: this.accountId, }, 'mailboxes'] ]; const response = await this.makeJmapRequest(methodCalls); return response.methodResponses[0][1].list; } async getEmails(mailboxId, limit = 50, position = 0) { const methodCalls = [ ['Email/query', { accountId: this.accountId, filter: mailboxId ? { inMailbox: mailboxId } : null, sort: [{ property: 'receivedAt', isAscending: false }], position: position, limit: limit }, 'query'], ['Email/get', { accountId: this.accountId, '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'from', 'to', 'receivedAt', 'size', 'preview'] }, 'emails'] ]; const response = await this.makeJmapRequest(methodCalls); const emails = response.methodResponses[1][1].list; const queryResult = response.methodResponses[0][1]; return { emails: emails, total: queryResult.total, position: queryResult.position, hasMore: (queryResult.position + emails.length) < queryResult.total, queryState: queryResult.queryState }; } async getAllEmails(mailboxId, batchSize = 50, maxTotal = 1000) { const allEmails = []; let position = 0; let hasMore = true; console.log(`πŸ“¬ Getting emails from mailbox ${mailboxId || 'all'} with pagination...`); while (hasMore && allEmails.length < maxTotal) { const remainingLimit = Math.min(batchSize, maxTotal - allEmails.length); console.log(`πŸ“€ Fetching batch at position ${position}, limit ${remainingLimit}`); const result = await this.getEmails(mailboxId, remainingLimit, position); if (result.emails && result.emails.length > 0) { allEmails.push(...result.emails); console.log(`πŸ“₯ Retrieved ${result.emails.length} emails, total so far: ${allEmails.length}`); position += result.emails.length; hasMore = result.hasMore; // Prevent infinite loops - if we didn't get expected results if (result.emails.length === 0) { console.log('⚠️ No more emails in batch, stopping pagination'); break; } } else { console.log('πŸ“­ No more emails to retrieve'); break; } // Small delay to prevent overwhelming the server if (hasMore && allEmails.length < maxTotal) { await new Promise(resolve => setTimeout(resolve, 100)); } } console.log(`βœ… Retrieved ${allEmails.length} total emails`); return allEmails; } async getEmailById(emailId) { const methodCalls = [ ['Email/get', { accountId: this.accountId, ids: [emailId], properties: ['id', 'subject', 'from', 'to', 'cc', 'bcc', 'receivedAt', 'size', 'preview', 'bodyStructure', 'bodyValues'] }, 'email'] ]; const response = await this.makeJmapRequest(methodCalls); return response.methodResponses[0][1].list[0]; } generateEmailKey(to, subject, textBody) { // Create a unique key for this email to detect duplicates const recipients = Array.isArray(to) ? to.sort().join(',') : to; const contentHash = Buffer.from(`${recipients}::${subject}::${textBody.substring(0, 200)}`).toString('base64'); return contentHash; } cleanExpiredCacheEntries() { const now = Date.now(); for (const [key, timestamp] of this.emailSendCache.entries()) { if (now - timestamp > this.duplicatePreventionWindow) { this.emailSendCache.delete(key); } } } async sendEmail(to, subject, textBody, htmlBody = null, fromAlias = null) { console.log(`πŸ“§ Sending email to: ${to}`); console.log(`πŸ“‹ Subject: ${subject}`); // Generate a unique key for this email to prevent duplicates const emailKey = this.generateEmailKey(to, subject, textBody); const now = Date.now(); // Clean expired cache entries this.cleanExpiredCacheEntries(); // Check if we've sent this exact email recently if (this.emailSendCache.has(emailKey)) { const lastSentTime = this.emailSendCache.get(emailKey); const timeSinceLastSend = now - lastSentTime; if (timeSinceLastSend < this.duplicatePreventionWindow) { const minutesAgo = Math.round(timeSinceLastSend / 1000 / 60); console.warn(`⚠️ DUPLICATE PREVENTION: This email was already sent ${minutesAgo} minutes ago`); console.warn(`πŸ“§ To: ${to}, Subject: ${subject}`); throw new Error(`Duplicate email prevented: This exact email was sent ${minutesAgo} minutes ago. Wait ${Math.ceil((this.duplicatePreventionWindow - timeSinceLastSend) / 1000 / 60)} more minutes to send again.`); } } // Record this email attempt this.emailSendCache.set(emailKey, now); console.log(`πŸ”’ Email recorded in duplicate prevention cache (key: ${emailKey.substring(0, 16)}...)`); // Now that blob upload works, try the full JMAP implementation console.log('πŸ”„ Attempting JMAP email sending with blobs...'); try { const result = await this.sendEmailJMAP(to, subject, textBody, htmlBody, fromAlias); console.log('βœ… JMAP send successful'); console.log(`πŸ“Š Email cache size: ${this.emailSendCache.size} recent sends`); return result; } catch (jmapError) { // Remove from cache if sending failed this.emailSendCache.delete(emailKey); console.error('❌ JMAP sending failed, removed from cache:', jmapError.message); throw new Error(`Email sending failed - JMAP: ${jmapError.message}`); } } async sendEmailSimpleJMAP(to, subject, textBody, htmlBody = null, fromAlias = null) { const fromEmail = fromAlias ? `${fromAlias}@${this.aliasDomain}` : this.sendAs; // Ensure we're authenticated if (!this.session) { await this.authenticate(); } // Get the Sent mailbox const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent' || mb.name === 'Sent'); if (!sentMailbox) { throw new Error('No Sent mailbox found'); } console.log(`πŸ“¬ Creating email in Sent folder (${sentMailbox.name})`); // Create email directly in Sent folder (simple approach) const email = { from: [{ email: fromEmail }], to: Array.isArray(to) ? to.map(email => ({ email })) : [{ email: to }], subject: subject, mailboxIds: { [sentMailbox.id]: true }, receivedAt: new Date().toISOString(), bodyStructure: { type: 'text/plain', bodyValue: 'textPart' }, bodyValues: { textPart: { value: textBody, charset: 'utf-8' } } }; if (htmlBody) { email.bodyStructure = { type: 'multipart/alternative', subParts: [ { type: 'text/plain', bodyValue: 'textPart' }, { type: 'text/html', bodyValue: 'htmlPart' } ] }; email.bodyValues.htmlPart = { value: htmlBody, charset: 'utf-8' }; } const methodCalls = [ ['Email/set', { accountId: this.accountId, create: { 'email1': email } }, 'createEmail'] ]; const response = await this.makeJmapRequest(methodCalls); if (response.methodResponses[0][1].created) { const emailId = response.methodResponses[0][1].created['email1'].id; console.log(`βœ… Email created in Sent folder with ID: ${emailId}`); // Now try to actually send it via a simple web request to the recipient // This is a workaround since we can't use SMTP with current credentials console.log('πŸ“€ Note: Email saved to Sent folder. For actual delivery, manual sending may be required.'); return { success: true, method: 'SimpleJMAP', emailId: emailId, note: 'Email created in Sent folder. Actual delivery depends on FastMail processing.' }; } else { throw new Error(`Email creation failed: ${JSON.stringify(response.methodResponses[0][1])}`); } } async sendEmailJMAP(to, subject, textBody, htmlBody = null, fromAlias = null) { const fromEmail = fromAlias ? `${fromAlias}@${this.aliasDomain}` : this.sendAs; // Ensure we're authenticated if (!this.session) { await this.authenticate(); } // Get mailboxes const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent' || mb.name === 'Sent'); if (!sentMailbox) { throw new Error('No Sent mailbox found for JMAP sending'); } // Step 1: Upload content as blobs console.log('πŸ“€ Uploading email content as blobs...'); const textBlobId = await this.uploadBlob(textBody); let htmlBlobId = null; if (htmlBody) { htmlBlobId = await this.uploadBlob(htmlBody); } // Step 2: Create email with proper blob structure console.log('πŸ“ Creating email object...'); const email = { from: [{ email: fromEmail }], to: Array.isArray(to) ? to.map(email => ({ email })) : [{ email: to }], subject: subject, mailboxIds: { [sentMailbox.id]: true }, bodyStructure: htmlBody ? { type: 'multipart/alternative', subParts: [ { type: 'text/plain', blobId: textBlobId, charset: 'utf-8', disposition: 'inline' }, { type: 'text/html', blobId: htmlBlobId, charset: 'utf-8' } ] } : { type: 'text/plain', blobId: textBlobId, charset: 'utf-8', disposition: 'inline' } }; const emailResponse = await this.makeJmapRequestWithCapabilities(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], [ ['Email/set', { accountId: this.accountId, create: { 'email1': email } }, 'email'] ]); if (!emailResponse.methodResponses[0][1].created) { throw new Error(`Email creation failed: ${JSON.stringify(emailResponse.methodResponses[0][1])}`); } const emailId = emailResponse.methodResponses[0][1].created['email1'].id; console.log('βœ… Email created with ID:', emailId); // Step 3: Submit email for sending console.log('πŸ“¬ Submitting email for sending...'); // Get the correct identity for the sending email let identityId = '154447007'; // Default to clark@clarkeverson.com identity if (fromEmail === 'clark@everson.dev') { identityId = '154446543'; } else if (fromEmail.endsWith('@clarkeverson.com')) { // Use the catch-all identity for other @clarkeverson.com addresses identityId = '154446999'; } console.log(`πŸ†” Using identity ID: ${identityId} for ${fromEmail}`); const submissionResponse = await this.makeJmapRequestWithCapabilities(['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission'], [ ['EmailSubmission/set', { accountId: this.accountId, create: { 'submission1': { emailId: emailId, identityId: identityId, envelope: { mailFrom: { email: fromEmail }, rcptTo: Array.isArray(to) ? to.map(email => ({ email })) : [{ email: to }] } } } }, 'submission'] ]); if (!submissionResponse.methodResponses[0][1].created) { throw new Error(`Email submission failed: ${JSON.stringify(submissionResponse.methodResponses[0][1])}`); } console.log('βœ… Email submitted successfully'); return { success: true, method: 'JMAP', emailId: emailId, submissionId: submissionResponse.methodResponses[0][1].created['submission1'].id }; } async uploadBlob(content) { if (!this.session || !this.session.uploadUrl) { throw new Error('No upload URL available in session'); } // Replace {accountId} in the upload URL template const uploadUrl = this.session.uploadUrl.replace('{accountId}', this.accountId); console.log(`πŸ“€ Uploading blob to: ${uploadUrl}`); const response = await fetch(uploadUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiToken}`, 'Content-Type': 'application/octet-stream', }, body: content }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Blob upload failed: ${response.status} ${response.statusText} - ${errorText}`); } const result = await response.json(); console.log('πŸ“¦ Blob upload response:', result); if (!result.blobId) { throw new Error('No blobId returned from upload'); } return result.blobId; } async makeJmapRequestWithCapabilities(capabilities, methodCalls) { if (!this.session) { await this.authenticate(); } const requestBody = { using: capabilities, methodCalls: methodCalls }; const response = await fetch(this.session.apiUrl, { method: 'POST', headers: { 'Authorization': `Bearer ${this.apiToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`JMAP request failed: ${response.status} ${response.statusText}`); } return await response.json(); } async sendEmailSMTP(to, subject, textBody, htmlBody = null, fromAlias = null) { try { const nodemailer = (await import('nodemailer')).default; const fromEmail = fromAlias ? `${fromAlias}@${this.aliasDomain}` : this.sendAs; console.log(`πŸ” SMTP Auth - User: ${this.sendAs}`); console.log(`πŸ“§ From: ${fromEmail}`); console.log(`πŸ“¬ To: ${Array.isArray(to) ? to.join(', ') : to}`); // FastMail SMTP configuration const transporter = nodemailer.createTransport({ host: 'smtp.fastmail.com', port: 587, secure: false, // TLS requireTLS: true, auth: { user: this.sendAs, // Try using sendAs email instead of this.email pass: this.apiToken // Use app password for SMTP }, debug: false, // Set to true for detailed logs logger: false }); // Verify connection first console.log('πŸ” Verifying SMTP connection...'); await transporter.verify(); console.log('βœ… SMTP connection verified'); const mailOptions = { from: fromEmail, to: Array.isArray(to) ? to.join(', ') : to, subject: subject, text: textBody, html: htmlBody || null, envelope: { from: fromEmail, to: Array.isArray(to) ? to : [to] } }; console.log('πŸš€ Sending email via SMTP...'); const info = await transporter.sendMail(mailOptions); console.log('βœ… SMTP send successful!'); console.log(`πŸ“‹ Message ID: ${info.messageId}`); console.log(`πŸ“Š Response: ${info.response}`); // Close the transporter transporter.close(); return { success: true, method: 'SMTP', messageId: info.messageId, response: info.response, accepted: info.accepted, rejected: info.rejected }; } catch (error) { console.error('❌ SMTP send failed with error:', error); console.error('❌ Error code:', error.code); console.error('❌ Error command:', error.command); throw new Error(`SMTP sending failed: ${error.message} (${error.code || 'unknown'})`); } } async searchEmails(query, limit = 50, position = 0) { const methodCalls = [ ['Email/query', { accountId: this.accountId, filter: { text: query }, sort: [{ property: 'receivedAt', isAscending: false }], position: position, limit: limit }, 'query'], ['Email/get', { accountId: this.accountId, '#ids': { resultOf: 'query', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'from', 'to', 'receivedAt', 'size', 'preview'] }, 'emails'] ]; const response = await this.makeJmapRequest(methodCalls); const emails = response.methodResponses[1][1].list; const queryResult = response.methodResponses[0][1]; return { emails: emails, total: queryResult.total, position: queryResult.position, hasMore: (queryResult.position + emails.length) < queryResult.total, queryState: queryResult.queryState }; } async searchAllEmails(query, batchSize = 50, maxTotal = 500) { const allEmails = []; let position = 0; let hasMore = true; console.log(`πŸ” Searching emails for "${query}" with pagination...`); while (hasMore && allEmails.length < maxTotal) { const remainingLimit = Math.min(batchSize, maxTotal - allEmails.length); console.log(`πŸ“€ Searching batch at position ${position}, limit ${remainingLimit}`); const result = await this.searchEmails(query, remainingLimit, position); if (result.emails && result.emails.length > 0) { allEmails.push(...result.emails); console.log(`πŸ“₯ Found ${result.emails.length} emails, total so far: ${allEmails.length}`); position += result.emails.length; hasMore = result.hasMore; if (result.emails.length === 0) { console.log('⚠️ No more emails in search batch, stopping pagination'); break; } } else { console.log('πŸ” No more search results'); break; } // Small delay to prevent overwhelming the server if (hasMore && allEmails.length < maxTotal) { await new Promise(resolve => setTimeout(resolve, 100)); } } console.log(`βœ… Found ${allEmails.length} total emails matching "${query}"`); return allEmails; } async createMailbox(name, parentId = null, role = null) { const mailboxData = { name: name, sortOrder: 10 }; if (parentId) { mailboxData.parentId = parentId; } if (role) { mailboxData.role = role; } const methodCalls = [ ['Mailbox/set', { accountId: this.accountId, create: { 'new-mailbox': mailboxData } }, 'createMailbox'] ]; const response = await this.makeJmapRequest(methodCalls); if (response.methodResponses[0][1].created) { return response.methodResponses[0][1].created['new-mailbox']; } else { throw new Error(`Failed to create mailbox: ${JSON.stringify(response.methodResponses[0][1])}`); } } /** * Create a hierarchical mailbox structure from path like "Financial/Banking" * @param {string} hierarchicalPath - Path like "Financial/Banking" * @returns {Object} - The final mailbox object */ async createHierarchicalMailbox(hierarchicalPath) { const parts = hierarchicalPath.split('/'); const existingMailboxes = await this.getMailboxes(); let currentParentId = null; let currentPath = ''; for (let i = 0; i < parts.length; i++) { const part = parts[i]; currentPath = currentPath ? `${currentPath}/${part}` : part; // Check if this level already exists let existing = null; if (currentParentId) { // Look for child with this name under the current parent existing = existingMailboxes.find(mb => mb.name === part && mb.parentId === currentParentId ); } else { // Look for top-level mailbox with this name existing = existingMailboxes.find(mb => mb.name === part && !mb.parentId ); } if (existing) { currentParentId = existing.id; } else { // Create this level console.log(`πŸ“ Creating hierarchical folder: ${currentPath}`); const newMailbox = await this.createMailbox(part, currentParentId); currentParentId = newMailbox.id; // Add to our tracking existingMailboxes.push({ id: newMailbox.id, name: part, parentId: currentParentId === newMailbox.id ? null : currentParentId }); } } // Return the final mailbox return existingMailboxes.find(mb => mb.id === currentParentId); } async moveEmailsToMailbox(emailIds, mailboxId) { const methodCalls = [ ['Email/set', { accountId: this.accountId, update: {} }, 'moveEmails'] ]; // Build update object for each email emailIds.forEach(emailId => { methodCalls[0][1].update[emailId] = { mailboxIds: { [mailboxId]: true } }; }); const response = await this.makeJmapRequest(methodCalls); if (response.methodResponses[0][1].updated) { return response.methodResponses[0][1].updated; } else { throw new Error(`Failed to move emails: ${JSON.stringify(response.methodResponses[0][1])}`); } } /** * Assign emails to multiple mailboxes (multi-label support) * @param {Array} emailIds - Array of email IDs * @param {Array} mailboxIds - Array of mailbox IDs to assign * @param {boolean} preserveExisting - Whether to keep existing mailbox assignments * @returns {Object} - Update result */ async assignEmailsToMailboxes(emailIds, mailboxIds, preserveExisting = false) { if (!Array.isArray(emailIds) || !Array.isArray(mailboxIds)) { throw new Error('emailIds and mailboxIds must be arrays'); } if (emailIds.length === 0 || mailboxIds.length === 0) { throw new Error('emailIds and mailboxIds cannot be empty'); } // Get current mailbox assignments if preserving existing let currentAssignments = {}; if (preserveExisting) { const emailsResponse = await this.makeJmapRequest([ ['Email/get', { accountId: this.accountId, ids: emailIds, properties: ['id', 'mailboxIds'] }, 'getCurrentAssignments'] ]); const emails = emailsResponse.methodResponses[0][1].list; emails.forEach(email => { currentAssignments[email.id] = email.mailboxIds || {}; }); } const methodCalls = [ ['Email/set', { accountId: this.accountId, update: {} }, 'assignToMultipleMailboxes'] ]; // Build update object for each email emailIds.forEach(emailId => { const newMailboxIds = {}; // Add existing mailboxes if preserving if (preserveExisting && currentAssignments[emailId]) { Object.assign(newMailboxIds, currentAssignments[emailId]); } // Add new mailboxes mailboxIds.forEach(mailboxId => { newMailboxIds[mailboxId] = true; }); methodCalls[0][1].update[emailId] = { mailboxIds: newMailboxIds }; }); const response = await this.makeJmapRequest(methodCalls); if (response.methodResponses[0][1].updated) { return { updated: response.methodResponses[0][1].updated, assignedToMailboxes: mailboxIds.length, emailsProcessed: emailIds.length }; } else { throw new Error(`Failed to assign emails to mailboxes: ${JSON.stringify(response.methodResponses[0][1])}`); } } /** * Find or create multiple hierarchical mailboxes by path * @param {Array} mailboxNames - Array of hierarchical mailbox paths like ["Financial/Banking", "Commerce/Orders"] * @returns {Array} - Array of mailbox objects with IDs */ async findOrCreateMultipleMailboxes(mailboxNames) { if (!Array.isArray(mailboxNames)) { throw new Error('mailboxNames must be an array'); } const results = []; // Create missing hierarchical mailboxes for (const name of mailboxNames) { try { // For hierarchical paths, we need to find the final mailbox const existingMailboxes = await this.getMailboxes(); let existing = this.findHierarchicalMailbox(existingMailboxes, name); if (existing) { results.push({ name, mailbox: existing, created: false }); } else { const newMailbox = await this.createHierarchicalMailbox(name); results.push({ name, mailbox: newMailbox, created: true }); } } catch (error) { console.error(`Failed to create hierarchical mailbox ${name}:`, error); // Add error result results.push({ name, mailbox: null, created: false, error: error.message }); } } return results; } /** * Find a hierarchical mailbox by path like "Financial/Banking" * @param {Array} mailboxes - Array of all mailboxes * @param {string} hierarchicalPath - Path like "Financial/Banking" * @returns {Object|null} - The mailbox object or null if not found */ findHierarchicalMailbox(mailboxes, hierarchicalPath) { const parts = hierarchicalPath.split('/'); let currentParentId = null; let currentMailbox = null; for (const part of parts) { if (currentParentId === null) { // Look for top-level mailbox currentMailbox = mailboxes.find(mb => mb.name === part && !mb.parentId); } else { // Look for child mailbox currentMailbox = mailboxes.find(mb => mb.name === part && mb.parentId === currentParentId); } if (!currentMailbox) { return null; // Path doesn't exist } currentParentId = currentMailbox.id; } return currentMailbox; } async findMailboxByName(name) { const mailboxes = await this.getMailboxes(); return mailboxes.find(mailbox => mailbox.name === name); } async findOrCreateMailbox(name, parentId = null) { // First try to find existing mailbox const existing = await this.findMailboxByName(name); if (existing) { return existing; } // Create new mailbox if it doesn't exist return await this.createMailbox(name, parentId); } /** * Delete a mailbox by ID * @param {string} mailboxId - The ID of the mailbox to delete * @returns {Object} - The deletion result */ async deleteMailbox(mailboxId) { const methodCalls = [ ['Mailbox/set', { accountId: this.accountId, destroy: [mailboxId] }, 'deleteMailbox'] ]; const response = await this.makeJmapRequest(methodCalls); if (response.methodResponses[0][1].destroyed && response.methodResponses[0][1].destroyed.includes(mailboxId)) { return { success: true, deletedId: mailboxId }; } else { throw new Error(`Failed to delete mailbox: ${JSON.stringify(response.methodResponses[0][1])}`); } } /** * Get current mailbox assignments for emails * @param {Array} emailIds - Array of email IDs * @returns {Object} - Map of email ID to mailbox IDs */ async getEmailMailboxAssignments(emailIds) { if (!Array.isArray(emailIds) || emailIds.length === 0) { return {}; } const response = await this.makeJmapRequest([ ['Email/get', { accountId: this.accountId, ids: emailIds, properties: ['id', 'mailboxIds'] }, 'getAssignments'] ]); const assignments = {}; const emails = response.methodResponses[0][1].list; emails.forEach(email => { assignments[email.id] = email.mailboxIds || {}; }); return assignments; } }

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/gr3enarr0w/fastmail-mcp-server'

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