import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress } from "./types"; // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { apiUrl: string; downloadUrl: string; uploadUrl?: string; eventSourceUrl?: string; primaryAccounts?: Record; accounts?: Record; capabilities?: Record; } interface JMAPAccount { name?: string; isPersonal?: boolean; isReadOnly?: boolean; accountCapabilities?: Record; } interface JMAPQuota { resourceType?: string; scope?: string; used?: number; hardLimit?: number; limit?: number; } interface JMAPMailbox { id: string; name: string; parentId?: string | null; role?: string | null; totalEmails?: number; unreadEmails?: number; totalThreads?: number; unreadThreads?: number; sortOrder?: number; isSubscribed?: boolean; myRights?: Record; } interface JMAPEmailHeader { name: string; value: string; } // Generic JMAP method call type type JMAPMethodCall = [string, Record, string]; // JMAP response types - using flexible types due to protocol variations // eslint-disable-next-line @typescript-eslint/no-explicit-any type JMAPResponseResult = Record; interface JMAPResponse { methodResponses: Array<[string, JMAPResponseResult, string]>; } export class JMAPClient { private serverUrl: string; private username: string; private password: string; private authHeader: string; private apiUrl: string = ""; private accountId: string = ""; private downloadUrl: string = ""; private capabilities: Record = {}; private session: JMAPSession | null = null; private lastPingTime: number = 0; private pingInterval: NodeJS.Timeout | null = null; private accounts: Record = {}; // All accounts (primary + shared) private eventSource: EventSource | null = null; private stateChangeCallback: ((change: StateChange) => void) | null = null; private lastStates: AccountStates = {}; constructor(serverUrl: string, username: string, password: string) { this.serverUrl = serverUrl.replace(/\/$/, ''); this.username = username; this.password = password; this.authHeader = `Basic ${btoa(`${username}:${password}`)}`; } async connect(): Promise { // Get the session first const sessionUrl = `${this.serverUrl}/.well-known/jmap`; try { const sessionResponse = await fetch(sessionUrl, { method: 'GET', headers: { 'Authorization': this.authHeader, }, }); if (!sessionResponse.ok) { if (sessionResponse.status === 401) { throw new Error('Invalid username or password'); } throw new Error(`Failed to get session: ${sessionResponse.status}`); } const session = await sessionResponse.json(); // Store the full session for reference this.session = session; // Extract and store capabilities this.capabilities = session.capabilities || {}; // Extract the API URL this.apiUrl = session.apiUrl; // Extract the download URL this.downloadUrl = session.downloadUrl; // Extract and store all accounts (primary + shared) this.accounts = session.accounts || {}; // Extract the primary account ID const mailAccount = session.primaryAccounts?.["urn:ietf:params:jmap:mail"]; if (mailAccount) { this.accountId = mailAccount; } else { // Try to find any account if (this.accounts && Object.keys(this.accounts).length > 0) { this.accountId = Object.keys(this.accounts)[0]; } else { throw new Error('No mail account found in session'); } } // Start keep-alive mechanism this.startKeepAlive(); } catch (error) { console.error('Connection failed:', error); throw error; } } private startKeepAlive(): void { // Stop any existing interval this.stopKeepAlive(); // Ping every 30 seconds to keep the connection alive const PING_INTERVAL = 30000; // 30 seconds this.pingInterval = setInterval(async () => { try { await this.ping(); } catch (error) { console.error('Keep-alive ping failed:', error); // If ping fails, try to reconnect try { await this.reconnect(); } catch (reconnectError) { console.error('Reconnection failed:', reconnectError); } } }, PING_INTERVAL); } private stopKeepAlive(): void { if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } } async ping(): Promise { if (!this.apiUrl) { throw new Error('Not connected'); } const now = Date.now(); // Use Echo method for lightweight ping const response = await this.request([ ["Core/echo", { ping: "pong" }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Core/echo") { this.lastPingTime = now; } else { throw new Error('Ping failed'); } } async reconnect(): Promise { await this.connect(); } disconnect(): void { this.stopKeepAlive(); this.closePushNotifications(); this.apiUrl = ""; this.accountId = ""; this.session = null; this.capabilities = {}; } private async request(methodCalls: JMAPMethodCall[]): Promise { if (!this.apiUrl) { throw new Error('Not connected. Call connect() first.'); } const requestBody = { using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls: methodCalls, }; const response = await fetch(this.apiUrl, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }); const responseText = await response.text(); if (!response.ok) { console.error('Request failed:', response.status, responseText); throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`); } let data; try { data = JSON.parse(responseText); } catch { console.error('Failed to parse response:', responseText); throw new Error('Invalid JSON response from server'); } return data; } async getQuota(): Promise<{ used: number; total: number } | null> { try { const response = await this.request([ ["Quota/get", { accountId: this.accountId, }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Quota/get") { const quotas = (response.methodResponses[0][1].list || []) as JMAPQuota[]; // Find the mail quota if it exists const mailQuota = quotas.find((q) => q.resourceType === "mail" || q.scope === "mail"); if (mailQuota) { return { used: mailQuota.used ?? 0, total: mailQuota.hardLimit ?? mailQuota.limit ?? 0 }; } } return null; } catch { return null; } } async getMailboxes(): Promise { try { const response = await this.request([ ["Mailbox/get", { accountId: this.accountId, }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; // Map and ensure all required fields are present const mailboxes = rawMailboxes.map((mb) => { return { id: mb.id, originalId: undefined, // Primary account uses original IDs name: mb.name, parentId: mb.parentId || undefined, role: mb.role || undefined, sortOrder: mb.sortOrder ?? 0, totalEmails: mb.totalEmails ?? 0, unreadEmails: mb.unreadEmails ?? 0, totalThreads: mb.totalThreads ?? 0, unreadThreads: mb.unreadThreads ?? 0, myRights: mb.myRights || { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, }, isSubscribed: mb.isSubscribed ?? true, // Account info for primary account accountId: this.accountId, accountName: this.accounts[this.accountId]?.name || this.username, isShared: false, } as Mailbox; }); return mailboxes; } throw new Error('Unexpected response format'); } catch (error) { console.error('Failed to get mailboxes:', error); // Return default inbox with all required fields return [{ id: 'INBOX', originalId: undefined, name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, }, isSubscribed: true, accountId: this.accountId, accountName: this.username, isShared: false, }] as Mailbox[]; } } async getAllMailboxes(): Promise { try { const allMailboxes: Mailbox[] = []; // Get all account IDs const accountIds = Object.keys(this.accounts); // If no accounts, fallback to primary only if (accountIds.length === 0) { return this.getMailboxes(); } // Fetch mailboxes for each account for (const accountId of accountIds) { const account = this.accounts[accountId]; const isPrimary = accountId === this.accountId; try { const response = await this.request([ ["Mailbox/get", { accountId: accountId, }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; // Map mailboxes with account info const mailboxes = rawMailboxes.map((mb) => { return { id: isPrimary ? mb.id : `${accountId}:${mb.id}`, // Namespace shared mailbox IDs originalId: mb.id, // Keep original ID for JMAP queries name: mb.name, parentId: mb.parentId ? (isPrimary ? mb.parentId : `${accountId}:${mb.parentId}`) : undefined, role: mb.role || undefined, sortOrder: mb.sortOrder ?? 0, totalEmails: mb.totalEmails ?? 0, unreadEmails: mb.unreadEmails ?? 0, totalThreads: mb.totalThreads ?? 0, unreadThreads: mb.unreadThreads ?? 0, myRights: mb.myRights || { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, }, isSubscribed: mb.isSubscribed ?? true, // Account info accountId: accountId, accountName: account?.name || (isPrimary ? this.username : accountId), isShared: !isPrimary, } as Mailbox; }); allMailboxes.push(...mailboxes); } } catch (error) { console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); // Continue with other accounts even if one fails } } return allMailboxes; } catch (error) { console.error("Failed to fetch all mailboxes:", error); // Fallback to primary account mailboxes return this.getMailboxes(); } } async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { // Use provided accountId or fallback to primary account const targetAccountId = accountId || this.accountId; // Build filter - only add inMailbox if we have a mailboxId const filter: { inMailbox?: string } = {}; if (mailboxId && mailboxId !== '') { filter.inMailbox = mailboxId; } const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter: filter, sort: [{ property: "receivedAt", isAscending: false }], limit: limit, position: position, }, "0"], ["Email/get", { accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids", }, properties: [ "id", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "from", "to", "cc", "subject", "preview", "hasAttachment", ], }, "1"], ]); const queryResponse = response.methodResponses?.[0]?.[1]; const getResponse = response.methodResponses?.[1]?.[1]; if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) { const emails = getResponse.list || []; // Stalwart doesn't return 'total', so we use a different strategy: // If we got exactly 'limit' emails, there might be more // If we got fewer, we've reached the end const total = queryResponse?.total || 0; const hasMore = total > 0 ? (position + emails.length) < total // Use total if available : emails.length === limit; // Otherwise, check if we got a full page // If fetching from a shared account, namespace the mailboxIds to match our store const isSharedAccount = accountId && accountId !== this.accountId; if (isSharedAccount) { emails.forEach((email: Email) => { if (email.mailboxIds) { const namespacedMailboxIds: Record = {}; Object.keys(email.mailboxIds).forEach(mbId => { namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId]; }); email.mailboxIds = namespacedMailboxIds; } }); } return { emails, hasMore, total }; } return { emails: [], hasMore: false, total: 0 }; } catch (error) { console.error('Failed to get emails:', error); return { emails: [], hasMore: false, total: 0 }; } } async getEmail(emailId: string, accountId?: string): Promise { try { // Use provided accountId or fallback to primary account const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Email/get", { accountId: targetAccountId, ids: [emailId], properties: [ "id", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "sentAt", "from", "to", "cc", "bcc", "replyTo", "subject", "preview", "textBody", "htmlBody", "bodyValues", "hasAttachment", "attachments", "messageId", "inReplyTo", "references", "headers", ], fetchTextBodyValues: true, fetchHTMLBodyValues: true, fetchAllBodyValues: true, maxBodyValueBytes: 256000, }, "0"], ]); if (response.methodResponses?.[0]?.[0] === "Email/get") { const emails = response.methodResponses[0][1].list || []; const email = emails[0]; if (email) { // If fetching from a shared account, namespace the mailboxIds to match our store const isSharedAccount = accountId && accountId !== this.accountId; if (isSharedAccount && email.mailboxIds) { const namespacedMailboxIds: Record = {}; Object.keys(email.mailboxIds).forEach(mbId => { namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId]; }); email.mailboxIds = namespacedMailboxIds; } // Parse headers if available if (email.headers) { // Import the parsing functions const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers'); // Convert headers array to Record format if needed let headersRecord: Record; if (Array.isArray(email.headers)) { headersRecord = {}; (email.headers as JMAPEmailHeader[]).forEach((header) => { if (header && header.name && header.value) { // If header already exists, convert to array or append if (headersRecord[header.name]) { if (Array.isArray(headersRecord[header.name])) { (headersRecord[header.name] as string[]).push(header.value); } else { headersRecord[header.name] = [headersRecord[header.name] as string, header.value]; } } else { headersRecord[header.name] = header.value; } } }); // Replace array with record for easier access email.headers = headersRecord; } else { headersRecord = email.headers as Record; } // Parse Authentication-Results header const authResultsHeader = headersRecord['Authentication-Results']; if (authResultsHeader) { const headerValue = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader; email.authenticationResults = parseAuthenticationResults(headerValue); } // Parse Spam headers const spamHeaders = ['X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']; for (const header of spamHeaders) { if (headersRecord[header]) { const headerValue = Array.isArray(headersRecord[header]) ? headersRecord[header][0] : headersRecord[header]; const spamResult = parseSpamScore(headerValue as string); if (spamResult) { email.spamScore = spamResult.score; email.spamStatus = spamResult.status; break; } } } // Parse X-Spam-LLM header if (headersRecord['X-Spam-LLM']) { const llmHeader = Array.isArray(headersRecord['X-Spam-LLM']) ? headersRecord['X-Spam-LLM'][0] : headersRecord['X-Spam-LLM']; const llmResult = parseSpamLLM(llmHeader as string); if (llmResult) { email.spamLLM = llmResult; } } } return email; } return null; } return null; } catch (error) { console.error('Failed to get email:', error); return null; } } async markAsRead(emailId: string, read: boolean = true, accountId?: string): Promise { // Use provided accountId or fallback to primary account const targetAccountId = accountId || this.accountId; await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: { "keywords/$seen": read, }, }, }, "0"], ]); } async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise { if (emailIds.length === 0) return; const updates: Record = {}; emailIds.forEach(id => { updates[id] = { "keywords/$seen": read, }; }); await this.request([ ["Email/set", { accountId: this.accountId, update: updates, }, "0"], ]); } async toggleStar(emailId: string, starred: boolean): Promise { await this.request([ ["Email/set", { accountId: this.accountId, update: { [emailId]: { "keywords/$flagged": starred, }, }, }, "0"], ]); } async updateEmailKeywords(emailId: string, keywords: Record): Promise { await this.request([ ["Email/set", { accountId: this.accountId, update: { [emailId]: { keywords, }, }, }, "0"], ]); } async deleteEmail(emailId: string): Promise { await this.request([ ["Email/set", { accountId: this.accountId, destroy: [emailId], }, "0"], ]); } async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: { mailboxIds: { [trashMailboxId]: true }, }, }, }, "0"], ]); } async batchDeleteEmails(emailIds: string[]): Promise { if (emailIds.length === 0) return; await this.request([ ["Email/set", { accountId: this.accountId, destroy: emailIds, }, "0"], ]); } async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise { if (emailIds.length === 0) return; const updates: Record }> = {}; emailIds.forEach(id => { updates[id] = { mailboxIds: { [toMailboxId]: true }, }; }); await this.request([ ["Email/set", { accountId: this.accountId, update: updates, }, "0"], ]); } async moveEmail(emailId: string, toMailboxId: string): Promise { await this.request([ ["Email/set", { accountId: this.accountId, update: { [emailId]: { mailboxIds: { [toMailboxId]: true }, }, }, }, "0"], ]); } /** * Move email to Junk folder */ async markAsSpam(emailId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const mailboxes = await this.getMailboxes(); const junkMailbox = mailboxes.find(m => { if (accountId) { return m.role === 'junk' && m.accountId === accountId; } return m.role === 'junk' && !m.isShared; }); if (!junkMailbox) { throw new Error('Junk mailbox not found'); } const mailboxId = accountId && junkMailbox.originalId ? junkMailbox.originalId : junkMailbox.id; await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: { mailboxIds: { [mailboxId]: true }, }, }, }, "0"], ]); } /** * Undo spam - move email back from Junk to original mailbox */ async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: { mailboxIds: { [originalMailboxId]: true }, }, }, }, "0"], ]); } async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { // Use provided accountId or fallback to primary account const targetAccountId = accountId || this.accountId; // Build filter with text search, optionally scoped to a mailbox const filter: Record = { text: query }; if (mailboxId) { filter.inMailbox = mailboxId; } const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter: filter, sort: [{ property: "receivedAt", isAscending: false }], limit: limit, position: position, }, "0"], ["Email/get", { accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids", }, properties: [ "id", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "from", "to", "cc", "subject", "preview", "hasAttachment", ], }, "1"], ]); const queryResponse = response.methodResponses?.[0]?.[1]; const emails = response.methodResponses?.[1]?.[1]?.list || []; // Stalwart doesn't always return 'total', so we use a different strategy: // If we got exactly 'limit' emails, there might be more // If we got fewer, we've reached the end const total = queryResponse?.total || 0; const hasMore = total > 0 ? (position + emails.length) < total // Use total if available : emails.length === limit; // Otherwise, check if we got a full page return { emails, hasMore, total }; } catch (error) { console.error('Search failed:', error); return { emails: [], hasMore: false, total: 0 }; } } // Thread methods for conversation view async getThread(threadId: string, accountId?: string): Promise { try { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Thread/get", { accountId: targetAccountId, ids: [threadId], }, "0"], ]); if (response.methodResponses?.[0]?.[0] === "Thread/get") { const threads = response.methodResponses[0][1].list || []; return threads[0] || null; } return null; } catch (error) { console.error('Failed to get thread:', error); return null; } } async getThreadEmails(threadId: string, accountId?: string): Promise { try { const targetAccountId = accountId || this.accountId; // First get the thread to find all email IDs const thread = await this.getThread(threadId, accountId); if (!thread || !thread.emailIds || thread.emailIds.length === 0) { return []; } // Fetch all emails in the thread const response = await this.request([ ["Email/get", { accountId: targetAccountId, ids: thread.emailIds, properties: [ "id", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "from", "to", "cc", "subject", "preview", "hasAttachment", ], }, "0"], ]); if (response.methodResponses?.[0]?.[0] === "Email/get") { const emails = response.methodResponses[0][1].list || []; // If fetching from a shared account, namespace the mailboxIds const isSharedAccount = accountId && accountId !== this.accountId; if (isSharedAccount) { emails.forEach((email: Email) => { if (email.mailboxIds) { const namespacedMailboxIds: Record = {}; Object.keys(email.mailboxIds).forEach(mbId => { namespacedMailboxIds[`${accountId}:${mbId}`] = email.mailboxIds[mbId]; }); email.mailboxIds = namespacedMailboxIds; } }); } // Sort by receivedAt descending (newest first) return emails.sort((a: Email, b: Email) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() ); } return []; } catch (error) { console.error('Failed to get thread emails:', error); return []; } } async getIdentities(): Promise { try { const response = await this.request([ ["Identity/get", { accountId: this.accountId, }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Identity/get") { const identities = (response.methodResponses[0][1].list || []) as Identity[]; return identities; } return []; } catch (error) { console.error('Failed to get identities:', error); return []; } } async createIdentity( name: string, email: string, replyTo?: EmailAddress[], bcc?: EmailAddress[], textSignature?: string, htmlSignature?: string ): Promise { const response = await this.request([ ["Identity/set", { accountId: this.accountId, create: { "new-identity": { name, email, replyTo, bcc, textSignature, htmlSignature, } } }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Identity/set") { const result = response.methodResponses[0][1]; // Check for errors if (result.notCreated?.["new-identity"]) { const error = result.notCreated["new-identity"]; if (error.type === "forbidden") { throw new Error("You are not authorized to send from this email address"); } throw new Error(error.description || "Failed to create identity"); } // Return created identity const createdId = result.created?.["new-identity"]?.id; if (createdId) { // Fetch the full identity object const identities = await this.getIdentities(); const identity = identities.find(i => i.id === createdId); if (identity) return identity; } } throw new Error("Failed to create identity: Server response was unexpected. Check server logs."); } async updateIdentity( identityId: string, updates: { name?: string; replyTo?: EmailAddress[]; bcc?: EmailAddress[]; textSignature?: string; htmlSignature?: string; } ): Promise { const response = await this.request([ ["Identity/set", { accountId: this.accountId, update: { [identityId]: updates } }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Identity/set") { const result = response.methodResponses[0][1]; // Check for errors if (result.notUpdated?.[identityId]) { const error = result.notUpdated[identityId]; if (error.type === "notFound") { throw new Error("Identity not found (may have been deleted)"); } if (error.type === "forbidden") { throw new Error("You are not authorized to modify this identity"); } throw new Error(error.description || "Failed to update identity"); } return; } throw new Error("Failed to update identity: Server response was unexpected. Check server logs."); } async deleteIdentity(identityId: string): Promise { const response = await this.request([ ["Identity/set", { accountId: this.accountId, destroy: [identityId] }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Identity/set") { const result = response.methodResponses[0][1]; // Check for errors if (result.notDestroyed?.[identityId]) { const error = result.notDestroyed[identityId]; if (error.type === "forbidden") { throw new Error("This identity cannot be deleted"); } if (error.type === "notFound") { throw new Error("Identity not found (may already be deleted)"); } throw new Error(error.description || "Failed to delete identity"); } return; } throw new Error("Failed to delete identity: Server response was unexpected. Check server logs."); } async createDraft( to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }> ): Promise { // Find the drafts mailbox const mailboxes = await this.getMailboxes(); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); if (!draftsMailbox) { throw new Error('No drafts mailbox found'); } const emailId = `draft-${Date.now()}`; // Build email object with attachments if provided interface EmailDraft { from: { email: string }[]; to: { email: string }[]; cc?: { email: string }[]; bcc?: { email: string }[]; subject: string; keywords: Record; mailboxIds: Record; bodyValues: Record; textBody: { partId: string }[]; attachments?: { blobId: string; type: string; name: string; disposition: string }[]; } const emailData: EmailDraft = { from: [{ email: fromEmail || this.username }], to: to.map(email => ({ email })), cc: cc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })), subject: subject, keywords: { "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyValues: { "1": { value: body, }, }, textBody: [ { partId: "1", }, ], }; // Add attachments if provided if (attachments && attachments.length > 0) { emailData.attachments = attachments.map(att => ({ blobId: att.blobId, type: att.type, name: att.name, disposition: "attachment", })); } // If updating an existing draft, destroy it first then create new one // This is simpler than trying to update individual fields const methodCalls: JMAPMethodCall[] = []; if (draftId) { // Delete old draft methodCalls.push(["Email/set", { accountId: this.accountId, destroy: [draftId], }, "0"]); // Create new draft methodCalls.push(["Email/set", { accountId: this.accountId, create: { [emailId]: emailData }, }, "1"]); } else { // Just create new draft methodCalls.push(["Email/set", { accountId: this.accountId, create: { [emailId]: emailData }, }, "0"]); } const response = await this.request(methodCalls); // If we're updating (destroy + create), check the second response // Otherwise check the first response const responseIndex = draftId ? 1 : 0; if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") { const result = response.methodResponses[responseIndex][1]; // Check for errors if (result.notCreated || result.notUpdated) { const errors = result.notCreated || result.notUpdated; const firstError = Object.values(errors)[0] as { description?: string; type?: string }; console.error('Draft save error:', firstError); throw new Error(firstError?.description || firstError?.type || 'Failed to save draft'); } if (result.created?.[emailId]) { return result.created[emailId].id; } } console.error('Unexpected draft save response:', response); throw new Error('Failed to save draft'); } async sendEmail( to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string ): Promise { const emailId = draftId || `draft-${Date.now()}`; // Find the Sent mailbox const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { throw new Error('No sent mailbox found'); } // Use provided identity ID or fetch from server as fallback let finalIdentityId = identityId; if (!finalIdentityId) { const identityResponse = await this.request([ ["Identity/get", { accountId: this.accountId, }, "0"] ]); finalIdentityId = this.accountId; // fallback if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[]; if (identities.length > 0) { // Use the first identity (or find one matching the fromEmail/username) const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username)); finalIdentityId = matchingIdentity?.id || identities[0].id; } } } const methodCalls: JMAPMethodCall[] = []; // If we have a draftId, update it and remove draft keyword, move to Sent // Otherwise, create a new email in Sent if (draftId) { methodCalls.push(["Email/set", { accountId: this.accountId, update: { [draftId]: { "keywords/$draft": false, "keywords/$seen": true, mailboxIds: { [sentMailbox.id]: true }, }, }, }, "0"]); methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, create: { "1": { emailId: draftId, identityId: finalIdentityId, }, }, }, "1"]); } else { methodCalls.push(["Email/set", { accountId: this.accountId, create: { [emailId]: { from: [{ email: fromEmail || this.username }], to: to.map(email => ({ email })), cc: cc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })), subject: subject, keywords: { "$seen": true }, mailboxIds: { [sentMailbox.id]: true }, bodyValues: { "1": { value: body, }, }, textBody: [ { partId: "1", }, ], }, }, }, "0"]); methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId, }, }, }, "1"]); } const response = await this.request(methodCalls); // Check for errors in the response if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { if (methodName.endsWith('/error')) { console.error('JMAP method error:', result); throw new Error(result.description || `Failed to send email: ${result.type}`); } // Check for notCreated/notUpdated if (result.notCreated || result.notUpdated) { const errors = result.notCreated || result.notUpdated; const firstError = Object.values(errors)[0] as { description?: string; type?: string }; console.error('Email send error:', firstError); throw new Error(firstError?.description || firstError?.type || 'Failed to send email'); } } } } async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> { if (!this.session) { throw new Error('Not connected. Call connect() first.'); } // Get upload URL from session const uploadUrl = this.session.uploadUrl; if (!uploadUrl) { throw new Error('Upload URL not available'); } // Replace accountId in the upload URL const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId)); console.log('Uploading file to:', finalUploadUrl); console.log('File info:', { name: file.name, size: file.size, type: file.type }); const response = await fetch(finalUploadUrl, { method: 'POST', headers: { 'Authorization': this.authHeader, 'Content-Type': file.type || 'application/octet-stream', }, body: file, // Send the file directly as binary }); console.log('Upload response status:', response.status); if (!response.ok) { const errorText = await response.text(); console.error('Upload failed:', errorText); throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); } const responseText = await response.text(); console.log('Upload response body:', responseText); let result; try { result = JSON.parse(responseText); console.log('Parsed upload response:', JSON.stringify(result, null, 2)); } catch { console.error('Failed to parse upload response as JSON:', responseText); throw new Error('Invalid JSON response from upload'); } // Try different response formats // Format 1: Direct response { blobId, type, size } if (result.blobId) { console.log('Using direct response format'); return { blobId: result.blobId, size: result.size || file.size, type: result.type || file.type, }; } // Format 2: Nested under accountId { accountId: { blobId, type, size } } const blobInfo = result[this.accountId]; if (blobInfo && blobInfo.blobId) { console.log('Using accountId-nested response format'); return { blobId: blobInfo.blobId, size: blobInfo.size || file.size, type: blobInfo.type || file.type, }; } // If neither format works, show what we got console.error('Unexpected upload response format:', result); throw new Error('Invalid upload response: blobId not found'); } getBlobDownloadUrl(blobId: string, name?: string, type?: string): string { if (!this.downloadUrl) { throw new Error('Download URL not available. Please reconnect.'); } // The downloadUrl is a URI Template (RFC 6570 level 1) with variables // like {accountId}, {blobId}, {name}, and {type} let url = this.downloadUrl; // Replace template variables with actual values url = url.replace('{accountId}', encodeURIComponent(this.accountId)); url = url.replace('{blobId}', encodeURIComponent(blobId)); // Replace {name} - use a default if not provided const fileName = name || 'download'; url = url.replace('{name}', encodeURIComponent(fileName)); // Replace {type} - URL encode it since it may contain slashes (e.g., "application/pdf") // If type is not provided, use a generic binary type const mimeType = type || 'application/octet-stream'; url = url.replace('{type}', encodeURIComponent(mimeType)); return url; } // Capability checking methods getCapabilities(): Record { return this.capabilities; } hasCapability(capability: string): boolean { return capability in this.capabilities; } getMaxSizeUpload(): number { const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined; return coreCapability?.maxSizeUpload || 0; } getMaxCallsInRequest(): number { const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxCallsInRequest?: number } | undefined; return coreCapability?.maxCallsInRequest || 50; } getEventSourceUrl(): string | null { const session = this.session; if (!session) { return null; } // RFC 8620: eventSourceUrl is at session root level if (session.eventSourceUrl) { return session.eventSourceUrl; } // Some servers may put it in capabilities const coreCapability = session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined; if (coreCapability?.eventSourceUrl) { return coreCapability.eventSourceUrl; } return null; } getAccountId(): string { return this.accountId; } supportsEmailSubmission(): boolean { return this.hasCapability("urn:ietf:params:jmap:submission"); } supportsQuota(): boolean { return this.hasCapability("urn:ietf:params:jmap:quota"); } supportsVacationResponse(): boolean { return this.hasCapability("urn:ietf:params:jmap:vacationresponse"); } async downloadBlob(blobId: string, name?: string, type?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type); const response = await fetch(url, { headers: { 'Authorization': this.authHeader, }, }); if (!response.ok) { throw new Error(`Failed to download attachment: ${response.status}`); } // Get the blob from the response const blob = await response.blob(); // Create a temporary URL for the blob const blobUrl = URL.createObjectURL(blob); // Create a temporary anchor element and trigger download const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'download'; document.body.appendChild(a); a.click(); // Clean up document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } // Real-time Updates via Polling (EventSource has auth limitations with Basic Auth) private pollingInterval: NodeJS.Timeout | null = null; private pollingStates: { [key: string]: string } = {}; setupPushNotifications(): boolean { // Use polling instead of EventSource due to Basic Auth limitations // EventSource can't send Authorization headers, and URL-embedded credentials // get decoded by browsers, breaking auth for usernames/passwords with special chars // Initial state fetch this.fetchCurrentStates(); // Set up polling interval this.pollingInterval = setInterval(() => { this.checkForStateChanges(); }, 15000); // Poll every 15 seconds return true; } private async fetchCurrentStates(): Promise { try { // Get current states from server using JMAP query const response = await fetch(this.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': this.authHeader, }, body: JSON.stringify({ using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'], ['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'], ], }), }); if (response.ok) { const data = await response.json(); // Extract states from response for (const [method, result] of data.methodResponses) { if (method === 'Mailbox/get' && result.state) { this.pollingStates['Mailbox'] = result.state; } if (method === 'Email/get' && result.state) { this.pollingStates['Email'] = result.state; } } } } catch { // Silently fail - polling will retry } } private async checkForStateChanges(): Promise { try { const response = await fetch(this.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': this.authHeader, }, body: JSON.stringify({ using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], methodCalls: [ ['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'], ['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'], ], }), }); if (response.ok) { const data = await response.json(); const changes: { [key: string]: string } = {}; let hasChanges = false; for (const [method, result] of data.methodResponses) { if (method === 'Mailbox/get' && result.state) { if (this.pollingStates['Mailbox'] && this.pollingStates['Mailbox'] !== result.state) { changes['Mailbox'] = result.state; hasChanges = true; } this.pollingStates['Mailbox'] = result.state; } if (method === 'Email/get' && result.state) { if (this.pollingStates['Email'] && this.pollingStates['Email'] !== result.state) { changes['Email'] = result.state; hasChanges = true; } this.pollingStates['Email'] = result.state; } } if (hasChanges && this.stateChangeCallback) { this.stateChangeCallback({ '@type': 'StateChange', changed: { [this.accountId]: changes, }, }); } } } catch { // Silently fail - polling will retry } } closePushNotifications(): void { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.stateChangeCallback = null; this.pollingStates = {}; } onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; } getLastStates(): AccountStates { return { ...this.lastStates }; } setLastStates(states: AccountStates): void { this.lastStates = { ...states }; } }