import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import { toWildcardQuery } from "./search-utils"; // 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; } type JMAPMethodCall = [string, Record, string]; // eslint-disable-next-line @typescript-eslint/no-explicit-any type JMAPResponseResult = Record; interface JMAPResponse { methodResponses: Array<[string, JMAPResponseResult, string]>; } const DEFAULT_MAILBOX_RIGHTS = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, } as const; const EMAIL_LIST_PROPERTIES = [ "id", "threadId", "mailboxIds", "keywords", "size", "receivedAt", "from", "to", "cc", "subject", "preview", "hasAttachment", ] as const; function namespaceMailboxIds(emails: Email[], accountId: string): void { for (const email of emails) { if (!email.mailboxIds) continue; const namespaced: Record = {}; for (const mbId of Object.keys(email.mailboxIds)) { namespaced[`${accountId}:${mbId}`] = email.mailboxIds[mbId]; } email.mailboxIds = namespaced; } } function computeHasMore(position: number, emailCount: number, total: number, limit: number): boolean { if (total > 0) return (position + emailCount) < total; return emailCount === limit; } export class JMAPClient { private serverUrl: string; private username: string; private password: string; private authHeader: string; private authMode: 'basic' | 'bearer' = 'basic'; private onTokenRefresh?: () => Promise; 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 = {}; private eventSource: EventSource | null = null; private stateChangeCallback: ((change: StateChange) => void) | null = null; private lastStates: AccountStates = {}; private reconnecting = false; private connectionChangeCallback: ((connected: boolean) => void) | null = null; constructor(serverUrl: string, username: string, password: string) { this.serverUrl = serverUrl.replace(/\/$/, ''); this.username = username; this.password = password; this.authHeader = `Basic ${btoa(`${username}:${password}`)}`; } static withBearer( serverUrl: string, accessToken: string, username: string, onTokenRefresh?: () => Promise, ): JMAPClient { const client = new JMAPClient(serverUrl, username, ''); client.authMode = 'bearer'; client.authHeader = `Bearer ${accessToken}`; client.onTokenRefresh = onTokenRefresh; return client; } updateAccessToken(token: string): void { this.authHeader = `Bearer ${token}`; } private async authenticatedFetch(url: string, init?: Parameters[1]): Promise { const headers = { ...init?.headers as Record, 'Authorization': this.authHeader }; let response: Response; try { response = await fetch(url, { ...init, headers }); } catch (error) { // Network error: retry once after brief delay (transient proxy/connection issues) if (this.reconnecting) throw error; await new Promise(r => setTimeout(r, 1000)); response = await fetch(url, { ...init, headers }); } if (response.status === 401) { if (this.authMode === 'bearer' && this.onTokenRefresh) { const newToken = await this.onTokenRefresh(); if (newToken) { this.updateAccessToken(newToken); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await fetch(url, { ...init, headers: retryHeaders }); } } else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) { // JMAP session may have expired — re-establish and retry once this.reconnecting = true; try { await this.refreshSession(); this.connectionChangeCallback?.(true); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await fetch(url, { ...init, headers: retryHeaders }); } catch { // Session refresh failed — return original 401 response } finally { this.reconnecting = false; } } } return response; } private async refreshSession(): Promise { const sessionUrl = `${this.serverUrl}/.well-known/jmap`; const response = await fetch(sessionUrl, { method: 'GET', headers: { 'Authorization': this.authHeader }, }); if (!response.ok) { throw new Error(`Session refresh failed: ${response.status}`); } const session = await response.json(); this.rewriteSessionUrls(session); this.session = session; this.capabilities = session.capabilities || {}; this.apiUrl = session.apiUrl; this.downloadUrl = session.downloadUrl; this.accounts = session.accounts || {}; } async connect(): Promise { const sessionUrl = `${this.serverUrl}/.well-known/jmap`; try { const sessionResponse = await this.authenticatedFetch(sessionUrl, { method: 'GET', }); if (!sessionResponse.ok) { if (sessionResponse.status === 401) { throw new Error(this.authMode === 'bearer' ? 'Authentication failed - token may be expired' : 'Invalid username or password'); } throw new Error(`Failed to get session: ${sessionResponse.status}`); } const session = await sessionResponse.json(); this.rewriteSessionUrls(session); this.session = session; this.capabilities = session.capabilities || {}; this.apiUrl = session.apiUrl; this.downloadUrl = session.downloadUrl; this.accounts = session.accounts || {}; const mailAccount = session.primaryAccounts?.["urn:ietf:params:jmap:mail"]; const fallbackAccount = Object.keys(this.accounts)[0]; this.accountId = mailAccount || fallbackAccount; if (!this.accountId) { throw new Error('No mail account found in session'); } this.startKeepAlive(); } catch (error) { if (error instanceof TypeError && (error.message === 'Failed to fetch' || error.message.includes('NetworkError'))) { let serverReachable = false; try { await fetch(sessionUrl, { mode: 'no-cors' }); serverReachable = true; } catch { /* genuinely unreachable */ } if (serverReachable) { throw new Error('CORS_ERROR'); } } throw error; } } private startKeepAlive(): void { this.stopKeepAlive(); this.pingInterval = setInterval(async () => { try { await this.ping(); this.connectionChangeCallback?.(true); } catch (error) { console.error('Keep-alive ping failed:', error); this.connectionChangeCallback?.(false); try { await this.reconnect(); this.connectionChangeCallback?.(true); } catch (reconnectError) { console.error('Reconnection failed:', reconnectError); } } }, 30_000); } 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(); const response = await this.request([ ["Core/echo", { ping: "pong" }, "0"] ]); if (response.methodResponses?.[0]?.[0] !== "Core/echo") { throw new Error('Ping failed'); } this.lastPingTime = now; } async reconnect(): Promise { await this.connect(); } disconnect(): void { this.stopKeepAlive(); this.closePushNotifications(); this.apiUrl = ""; this.accountId = ""; this.session = null; this.capabilities = {}; } private rewriteSessionUrl(url: string): string { try { const parsed = new URL(url); const server = new URL(this.serverUrl); if (parsed.origin === server.origin) return url; const pathAndRest = url.slice(url.indexOf('/', url.indexOf('//') + 2)); return server.origin + pathAndRest; } catch { return url; } } private rewriteSessionUrls(session: JMAPSession): void { session.apiUrl = this.rewriteSessionUrl(session.apiUrl); session.downloadUrl = this.rewriteSessionUrl(session.downloadUrl); if (session.uploadUrl) { session.uploadUrl = this.rewriteSessionUrl(session.uploadUrl); } if (session.eventSourceUrl) { session.eventSourceUrl = this.rewriteSessionUrl(session.eventSourceUrl); } } private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise { if (!this.apiUrl) { throw new Error('Not connected. Call connect() first.'); } const requestBody = { using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls, }; const response = await this.authenticatedFetch(this.apiUrl, { method: 'POST', headers: { '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[]; 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[]; return rawMailboxes.map((mb) => ({ id: mb.id, originalId: undefined, 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 || DEFAULT_MAILBOX_RIGHTS, isSubscribed: mb.isSubscribed ?? true, accountId: this.accountId, accountName: this.accounts[this.accountId]?.name || this.username, isShared: false, }) as Mailbox); } throw new Error('Unexpected response format'); } catch (error) { console.error('Failed to get mailboxes:', error); return [{ id: 'INBOX', originalId: undefined, name: 'Inbox', role: 'inbox', sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: DEFAULT_MAILBOX_RIGHTS, isSubscribed: true, accountId: this.accountId, accountName: this.username, isShared: false, }] as Mailbox[]; } } async getAllMailboxes(): Promise { try { const allMailboxes: Mailbox[] = []; const accountIds = Object.keys(this.accounts); if (accountIds.length === 0) { return this.getMailboxes(); } 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[]; const mailboxes = rawMailboxes.map((mb) => ({ id: isPrimary ? mb.id : `${accountId}:${mb.id}`, originalId: mb.id, 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 || DEFAULT_MAILBOX_RIGHTS, isSubscribed: mb.isSubscribed ?? true, 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); } } return allMailboxes; } catch (error) { console.error("Failed to fetch all mailboxes:", error); return this.getMailboxes(); } } async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { const targetAccountId = accountId || this.accountId; const filter: { inMailbox?: string; hasKeyword?: string } = {}; if (mailboxId) { filter.inMailbox = mailboxId; } if (hasKeyword) { filter.hasKeyword = hasKeyword; } const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter, sort: [{ property: "receivedAt", isAscending: false }], limit, position, }, "0"], ["Email/get", { accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "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 || []; const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); } 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 getTagCounts(tagIds: string[]): Promise> { if (tagIds.length === 0) return {}; try { const methodCalls: JMAPMethodCall[] = []; for (let i = 0; i < tagIds.length; i++) { const keyword = `$label:${tagIds[i]}`; // Total count for this tag methodCalls.push(["Email/query", { accountId: this.accountId, filter: { hasKeyword: keyword }, limit: 0, calculateTotal: true, }, `total_${i}`]); // Unread count for this tag methodCalls.push(["Email/query", { accountId: this.accountId, filter: { operator: "AND", conditions: [ { hasKeyword: keyword }, { notKeyword: "$seen" }, ], }, limit: 0, calculateTotal: true, }, `unread_${i}`]); } const response = await this.request(methodCalls); const result: Record = {}; for (let i = 0; i < tagIds.length; i++) { const totalResp = response.methodResponses?.[i * 2]?.[1]; const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1]; result[tagIds[i]] = { total: totalResp?.total ?? 0, unread: unreadResp?.total ?? 0, }; } return result; } catch (error) { console.error('Failed to get tag counts:', error); return {}; } } async getEmail(emailId: string, accountId?: string): Promise { try { 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") { return null; } const email = (response.methodResponses[0][1].list || [])[0]; if (!email) return null; if (accountId && accountId !== this.accountId) { namespaceMailboxIds([email], accountId); } if (email.headers) { await this.parseEmailHeaders(email); } return email; } catch (error) { console.error('Failed to get email:', error); return null; } } private async parseEmailHeaders(email: Email): Promise { const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers'); let headersRecord: Record; if (Array.isArray(email.headers)) { headersRecord = {}; for (const header of email.headers as unknown as JMAPEmailHeader[]) { if (!header?.name || !header?.value) continue; const existing = headersRecord[header.name]; if (existing) { headersRecord[header.name] = Array.isArray(existing) ? [...existing, header.value] : [existing, header.value]; } else { headersRecord[header.name] = header.value; } } email.headers = headersRecord; } else { headersRecord = email.headers as Record; } const authResultsHeader = headersRecord['Authentication-Results']; if (authResultsHeader) { const value = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader; email.authenticationResults = parseAuthenticationResults(value); } for (const headerName of ['X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']) { if (!headersRecord[headerName]) continue; const value = Array.isArray(headersRecord[headerName]) ? headersRecord[headerName][0] : headersRecord[headerName]; const spamResult = parseSpamScore(value as string); if (spamResult) { email.spamScore = spamResult.score; email.spamStatus = spamResult.status; break; } } const llmHeader = headersRecord['X-Spam-LLM']; if (llmHeader) { const value = Array.isArray(llmHeader) ? llmHeader[0] : llmHeader; const llmResult = parseSpamLLM(value as string); if (llmResult) { email.spamLLM = llmResult; } } } async markAsRead(emailId: string, read: boolean = true, accountId?: string): Promise { 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 = Object.fromEntries(emailIds.map(id => [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 = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }])); await this.request([ ["Email/set", { accountId: this.accountId, update: updates }, "0"], ]); } async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: { mailboxIds: { [toMailboxId]: true }, }, }, }, "0"], ]); const result = response.methodResponses?.[0]?.[1]; if (result?.notUpdated?.[emailId]) { throw new Error(`Failed to move email: ${result.notUpdated[emailId].type || 'unknown error'}`); } } 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"], ]); } 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 createMailbox(name: string, parentId?: string): Promise { const createId = `new-${Date.now()}`; const createData: Record = { name }; if (parentId) { createData.parentId = parentId; } const response = await this.request([ ["Mailbox/set", { accountId: this.accountId, create: { [createId]: createData }, }, "0"], ]); const result = response.methodResponses?.[0]?.[1]; if (result?.notCreated?.[createId]) { throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`); } const created = result?.created?.[createId]; if (!created?.id) { throw new Error('Failed to create mailbox: no ID returned'); } return { id: created.id, name, parentId, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, myRights: DEFAULT_MAILBOX_RIGHTS, isSubscribed: true, accountId: this.accountId, accountName: this.accounts[this.accountId]?.name || this.username, isShared: false, }; } async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise { const response = await this.request([ ["Mailbox/set", { accountId: this.accountId, update: { [mailboxId]: changes }, }, "0"], ]); const result = response.methodResponses?.[0]?.[1]; if (result?.notUpdated?.[mailboxId]) { throw new Error(`Failed to update mailbox: ${result.notUpdated[mailboxId].type || 'unknown error'}`); } } async deleteMailbox(mailboxId: string): Promise { const response = await this.request([ ["Mailbox/set", { accountId: this.accountId, destroy: [mailboxId], }, "0"], ]); const result = response.methodResponses?.[0]?.[1]; if (result?.notDestroyed?.[mailboxId]) { throw new Error(`Failed to delete mailbox: ${result.notDestroyed[mailboxId].type || 'unknown error'}`); } } async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { const targetAccountId = accountId || this.accountId; // Use the JMAP "text" filter which searches across from, to, cc, bcc, // subject, and body. Stalwart's FTS engine supports wildcard prefix // matching (e.g. "pri*" matches "prime", "primary", "private", etc.) const wildcardQuery = toWildcardQuery(query); const textFilter: Record = { text: wildcardQuery }; let filter: Record; if (mailboxId) { filter = { operator: "AND", conditions: [ { inMailbox: mailboxId }, textFilter, ], }; } else { filter = textFilter; } const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter, sort: [{ property: "receivedAt", isAscending: false }], limit, position, }, "0"], ["Email/get", { accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], ]); const queryResponse = response.methodResponses?.[0]?.[1]; const emails = response.methodResponses?.[1]?.[1]?.list || []; const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); return { emails, hasMore, total }; } catch (error) { console.error('Search failed:', error); return { emails: [], hasMore: false, total: 0 }; } } async advancedSearchEmails( filter: Record, accountId?: string, limit: number = 50, position: number = 0 ): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter, sort: [{ property: "receivedAt", isAscending: false }], limit, position, }, "0"], ["Email/get", { accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], ]); const queryResponse = response.methodResponses?.[0]?.[1]; const emails = response.methodResponses?.[1]?.[1]?.list || []; const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); return { emails, hasMore, total }; } catch (error) { console.error('Advanced search failed:', error); throw error; } } 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; const thread = await this.getThread(threadId, accountId); if (!thread?.emailIds?.length) { return []; } const response = await this.request([ ["Email/get", { accountId: targetAccountId, ids: thread.emailIds, properties: [...EMAIL_LIST_PROPERTIES], }, "0"], ]); if (response.methodResponses?.[0]?.[0] === "Email/get") { const emails = response.methodResponses[0][1].list || []; if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); } 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") { return (response.methodResponses[0][1].list || []) as Identity[]; } 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]; 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"); } const createdId = result.created?.["new-identity"]?.id; if (createdId) { 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]; 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]; 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."); } private vacationUsing(): string[] { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"]; } async getVacationResponse(): Promise { const response = await this.request([ ["VacationResponse/get", { accountId: this.accountId, ids: ["singleton"], }, "0"] ], this.vacationUsing()); if (response.methodResponses?.[0]?.[0] === "VacationResponse/get") { const list = response.methodResponses[0][1].list || []; if (list.length > 0) { return list[0] as VacationResponse; } return { id: "singleton", isEnabled: false, fromDate: null, toDate: null, subject: "", textBody: "", htmlBody: null, }; } throw new Error("Failed to fetch vacation response: unexpected server response"); } async setVacationResponse(updates: Partial): Promise { const response = await this.request([ ["VacationResponse/set", { accountId: this.accountId, update: { "singleton": updates, }, }, "0"] ], this.vacationUsing()); if (response.methodResponses?.[0]?.[0] === "VacationResponse/set") { const result = response.methodResponses[0][1]; if (result.notUpdated?.["singleton"]) { const error = result.notUpdated["singleton"]; throw new Error(error.description || "Failed to update vacation response"); } return; } throw new Error("Failed to update vacation response"); } 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 }>, fromName?: string ): Promise { 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()}`; interface EmailDraft { from: { name?: string; 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: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }], to: to.map(email => ({ email })), cc: cc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })), subject, keywords: { "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyValues: { "1": { value: body } }, textBody: [{ partId: "1" }], }; if (attachments?.length) { emailData.attachments = attachments.map(att => ({ blobId: att.blobId, type: att.type, name: att.name, disposition: "attachment", })); } // Destroy old draft before creating replacement to avoid duplicates const methodCalls: JMAPMethodCall[] = []; if (draftId) { methodCalls.push(["Email/set", { accountId: this.accountId, destroy: [draftId], }, "0"]); methodCalls.push(["Email/set", { accountId: this.accountId, create: { [emailId]: emailData }, }, "1"]); } else { methodCalls.push(["Email/set", { accountId: this.accountId, create: { [emailId]: emailData }, }, "0"]); } const response = await this.request(methodCalls); const responseIndex = draftId ? 1 : 0; if (response.methodResponses?.[responseIndex]?.[0] === "Email/set") { const result = response.methodResponses[responseIndex][1]; 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, fromName?: string ): Promise { const emailId = draftId || `draft-${Date.now()}`; const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { throw new Error('No sent mailbox found'); } let finalIdentityId = identityId; if (!finalIdentityId) { const identityResponse = await this.request([ ["Identity/get", { accountId: this.accountId }, "0"] ]); finalIdentityId = this.accountId; if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[]; if (identities.length > 0) { const matchingIdentity = identities.find((id) => id.email === (fromEmail || this.username)); finalIdentityId = matchingIdentity?.id || identities[0].id; } } } const methodCalls: JMAPMethodCall[] = []; 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: [{ ...(fromName ? { name: fromName } : {}), email: fromEmail || this.username }], to: to.map(email => ({ email })), cc: cc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })), 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); 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}`); } 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.'); } const uploadUrl = this.session.uploadUrl; if (!uploadUrl) { throw new Error('Upload URL not available'); } const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId)); const response = await this.authenticatedFetch(finalUploadUrl, { method: 'POST', headers: { 'Content-Type': file.type || 'application/octet-stream' }, body: file, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to upload file: ${response.status} - ${errorText}`); } const responseText = await response.text(); let result; try { result = JSON.parse(responseText); } catch { throw new Error('Invalid JSON response from upload'); } // Direct format: { blobId, type, size } if (result.blobId) { return { blobId: result.blobId, size: result.size || file.size, type: result.type || file.type, }; } // Nested format: { [accountId]: { blobId, type, size } } const blobInfo = result[this.accountId]; if (blobInfo?.blobId) { return { blobId: blobInfo.blobId, size: blobInfo.size || file.size, type: blobInfo.type || file.type, }; } 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.'); } // RFC 6570 level 1 URI template expansion return this.downloadUrl .replace('{accountId}', encodeURIComponent(this.accountId)) .replace('{blobId}', encodeURIComponent(blobId)) .replace('{name}', encodeURIComponent(name || 'download')) .replace('{type}', encodeURIComponent(type || 'application/octet-stream')); } async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type); const response = await this.authenticatedFetch(url, {}); if (!response.ok) { throw new Error(`Failed to fetch blob: ${response.status}`); } const blob = await response.blob(); return URL.createObjectURL(blob); } 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 { if (!this.session) return null; // RFC 8620: session root level, with fallback to capabilities for some servers const coreCapability = this.session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined; return this.session.eventSourceUrl || coreCapability?.eventSourceUrl || null; } getAccountId(): string { return this.accountId; } getUsername(): string { return this.username || this.session?.accounts?.[this.accountId]?.name || ''; } 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"); } supportsContacts(): boolean { return this.hasCapability("urn:ietf:params:jmap:contacts"); } supportsCalendars(): boolean { return this.hasCapability("urn:ietf:params:jmap:calendars"); } supportsSieve(): boolean { return this.hasCapability("urn:ietf:params:jmap:sieve"); } getSieveAccountId(): string { const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"]; return sieveAccount || this.accountId; } private sieveUsing(): string[] { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"]; } getSieveCapabilities(): SieveCapabilities | null { const sieveAccountId = this.getSieveAccountId(); const accountInfo = this.accounts[sieveAccountId]; if (!accountInfo?.accountCapabilities) return null; const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"]; return (caps as SieveCapabilities) || null; } async getSieveScripts(): Promise { const response = await this.request([ ["SieveScript/get", { accountId: this.getSieveAccountId(), }, "0"] ], this.sieveUsing()); if (response.methodResponses?.[0]?.[0] === "SieveScript/get") { return (response.methodResponses[0][1].list || []) as SieveScript[]; } throw new Error('Failed to fetch Sieve scripts'); } async getSieveScriptContent(blobId: string): Promise { const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve'); const response = await this.authenticatedFetch(url, {}); if (!response.ok) throw new Error(`Failed to download script: ${response.status}`); return response.text(); } private async uploadSieveBlob(content: string): Promise { if (!this.session?.uploadUrl) { throw new Error('Upload URL not available'); } const uploadUrl = this.session.uploadUrl.replace( '{accountId}', encodeURIComponent(this.getSieveAccountId()) ); const response = await this.authenticatedFetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': 'application/sieve', }, body: content, }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to upload sieve script: ${response.status} - ${errorText.substring(0, 200)}`); } const result = await response.json(); if (result.blobId) return result.blobId; const blobInfo = result[this.getSieveAccountId()]; if (blobInfo?.blobId) return blobInfo.blobId; throw new Error('Invalid upload response: blobId not found'); } async createSieveScript(name: string, content: string, activate?: boolean): Promise { const blobId = await this.uploadSieveBlob(content); const accountId = this.getSieveAccountId(); const setArgs: Record = { accountId, create: { "new-script": { name, blobId } }, }; if (activate) { setArgs.onSuccessActivateScript = "#new-script"; } const response = await this.request([ ["SieveScript/set", setArgs, "0"] ], this.sieveUsing()); if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { const result = response.methodResponses[0][1]; if (result.notCreated?.["new-script"]) { const error = result.notCreated["new-script"]; throw new Error(error.description || "Failed to create sieve script"); } const createdId = result.created?.["new-script"]?.id; if (createdId) { const scripts = await this.getSieveScripts(); const script = scripts.find(s => s.id === createdId); if (script) return script; } } throw new Error("Failed to create sieve script"); } async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise { const blobId = await this.uploadSieveBlob(content); const accountId = this.getSieveAccountId(); const setArgs: Record = { accountId, update: { [scriptId]: { blobId } }, }; if (activate) { setArgs.onSuccessActivateScript = scriptId; } const response = await this.request([ ["SieveScript/set", setArgs, "0"] ], this.sieveUsing()); if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { const result = response.methodResponses[0][1]; if (result.notUpdated?.[scriptId]) { const error = result.notUpdated[scriptId]; throw new Error(error.description || "Failed to update sieve script"); } return; } throw new Error("Failed to update sieve script"); } async deleteSieveScript(scriptId: string): Promise { const accountId = this.getSieveAccountId(); const response = await this.request([ ["SieveScript/set", { accountId, destroy: [scriptId] }, "0"] ], this.sieveUsing()); if (response.methodResponses?.[0]?.[0] === "SieveScript/set") { const result = response.methodResponses[0][1]; if (result.notDestroyed?.[scriptId]) { const error = result.notDestroyed[scriptId]; throw new Error(error.description || "Failed to delete sieve script"); } return; } throw new Error("Failed to delete sieve script"); } async activateSieveScript(scriptId: string): Promise { const accountId = this.getSieveAccountId(); const response = await this.request([ ["SieveScript/set", { accountId, onSuccessActivateScript: scriptId, }, "0"] ], this.sieveUsing()); const [methodName, result] = response.methodResponses?.[0] || []; if (methodName === "error") { throw new Error(result?.description || "Failed to activate sieve script"); } if (methodName !== "SieveScript/set") { throw new Error("Failed to activate sieve script"); } } async deactivateSieveScript(): Promise { const accountId = this.getSieveAccountId(); const response = await this.request([ ["SieveScript/set", { accountId, onSuccessActivateScript: null, }, "0"] ], this.sieveUsing()); const [methodName, result] = response.methodResponses?.[0] || []; if (methodName === "error") { throw new Error(result?.description || "Failed to deactivate sieve script"); } if (methodName !== "SieveScript/set") { throw new Error("Failed to deactivate sieve script"); } } async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> { const blobId = await this.uploadSieveBlob(content); const accountId = this.getSieveAccountId(); const response = await this.request([ ["SieveScript/validate", { accountId, blobId, }, "0"] ], this.sieveUsing()); if (response.methodResponses?.[0]?.[0] === "SieveScript/validate") { const result = response.methodResponses[0][1]; if (result.error) { return { isValid: false, errors: [result.error.description || "Validation failed"] }; } return { isValid: true }; } if (response.methodResponses?.[0]?.[0]?.endsWith('/error')) { const error = response.methodResponses[0][1]; return { isValid: false, errors: [error.description || "Validation failed"] }; } return { isValid: false, errors: ['Unexpected validation response'] }; } getContactsAccountId(): string { const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"]; return contactsAccount || this.accountId; } getCalendarsAccountId(): string { const calendarsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:calendars"]; return calendarsAccount || this.accountId; } private contactUsing(): string[] { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"]; } private calendarUsing(): string[] { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"]; } async getAddressBooks(): Promise { try { const accountId = this.getContactsAccountId(); const response = await this.request([ ["AddressBook/get", { accountId }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "AddressBook/get") { return (response.methodResponses[0][1].list || []) as AddressBook[]; } return []; } catch (error) { console.error('Failed to get address books:', error); return []; } } async getContacts(addressBookId?: string): Promise { try { const accountId = this.getContactsAccountId(); const queryArgs: Record = { accountId, limit: 1000 }; if (addressBookId) { queryArgs.filter = { inAddressBook: addressBookId }; } const response = await this.request([ ["ContactCard/query", queryArgs, "0"], ["ContactCard/get", { accountId, "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, }, "1"], ], this.contactUsing()); if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { return (response.methodResponses[1][1].list || []) as ContactCard[]; } return []; } catch (error) { console.error('Failed to get contacts:', error); return []; } } async getContact(contactId: string): Promise { try { const accountId = this.getContactsAccountId(); const response = await this.request([ ["ContactCard/get", { accountId, ids: [contactId], }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "ContactCard/get") { const list = response.methodResponses[0][1].list || []; return list[0] || null; } return null; } catch (error) { console.error('Failed to get contact:', error); return null; } } async createContact(contact: Partial): Promise { const accountId = this.getContactsAccountId(); let addressBookIds = contact.addressBookIds; if (!addressBookIds || Object.keys(addressBookIds).length === 0) { const books = await this.getAddressBooks(); const defaultBook = books.find(b => b.isDefault) || books[0]; if (defaultBook) { addressBookIds = { [defaultBook.id]: true }; } } const response = await this.request([ ["ContactCard/set", { accountId, create: { "new-contact": { ...contact, addressBookIds, } } }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "ContactCard/set") { const result = response.methodResponses[0][1]; if (result.notCreated?.["new-contact"]) { const error = result.notCreated["new-contact"]; throw new Error(error.description || "Failed to create contact"); } const createdId = result.created?.["new-contact"]?.id; if (createdId) { const created = await this.getContact(createdId); if (created) return created; } } throw new Error("Failed to create contact"); } async updateContact(contactId: string, updates: Partial): Promise { const accountId = this.getContactsAccountId(); const response = await this.request([ ["ContactCard/set", { accountId, update: { [contactId]: updates } }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "ContactCard/set") { const result = response.methodResponses[0][1]; if (result.notUpdated?.[contactId]) { const error = result.notUpdated[contactId]; throw new Error(error.description || "Failed to update contact"); } return; } throw new Error("Failed to update contact"); } async deleteContact(contactId: string): Promise { const accountId = this.getContactsAccountId(); const response = await this.request([ ["ContactCard/set", { accountId, destroy: [contactId] }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "ContactCard/set") { const result = response.methodResponses[0][1]; if (result.notDestroyed?.[contactId]) { const error = result.notDestroyed[contactId]; throw new Error(error.description || "Failed to delete contact"); } return; } throw new Error("Failed to delete contact"); } async searchContacts(query: string): Promise { try { const accountId = this.getContactsAccountId(); const response = await this.request([ ["ContactCard/query", { accountId, filter: { text: query }, limit: 50, }, "0"], ["ContactCard/get", { accountId, "#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" }, }, "1"] ], this.contactUsing()); if (response.methodResponses?.[1]?.[0] === "ContactCard/get") { return (response.methodResponses[1][1].list || []) as ContactCard[]; } return []; } catch (error) { console.error('Failed to search contacts:', error); return []; } } async getCalendars(): Promise { try { const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/get", { accountId }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/get") { return (response.methodResponses[0][1].list || []) as Calendar[]; } return []; } catch (error) { console.error('Failed to get calendars:', error); return []; } } async createCalendar(calendar: Partial): Promise { const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { accountId, create: { "new-calendar": calendar } }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/set") { const result = response.methodResponses[0][1]; if (result.notCreated?.["new-calendar"]) { const error = result.notCreated["new-calendar"]; throw new Error(error.description || "Failed to create calendar"); } const createdId = result.created?.["new-calendar"]?.id; if (createdId) { const calendars = await this.getCalendars(); const created = calendars.find(c => c.id === createdId); if (created) return created; } } throw new Error("Failed to create calendar"); } async updateCalendar(calendarId: string, updates: Partial): Promise { const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { accountId, update: { [calendarId]: updates } }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/set") { const result = response.methodResponses[0][1]; if (result.notUpdated?.[calendarId]) { const error = result.notUpdated[calendarId]; throw new Error(error.description || "Failed to update calendar"); } return; } throw new Error("Failed to update calendar"); } async deleteCalendar(calendarId: string): Promise { const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { accountId, destroy: [calendarId], onDestroyRemoveEvents: true }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/set") { const result = response.methodResponses[0][1]; if (result.notDestroyed?.[calendarId]) { const error = result.notDestroyed[calendarId]; throw new Error(error.description || "Failed to delete calendar"); } return; } throw new Error("Failed to delete calendar"); } async getCalendarEvents(calendarIds?: string[]): Promise { const accountId = this.getCalendarsAccountId(); const queryArgs: Record = { accountId, limit: 1000 }; if (calendarIds && calendarIds.length > 0) { queryArgs.filter = { inCalendars: calendarIds }; } const response = await this.request([ ["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/get", { accountId, "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" }, }, "1"] ], this.calendarUsing()); // Check for JMAP method-level errors if (response.methodResponses?.[0]?.[0] === "error") { const error = response.methodResponses[0][1]; throw new Error(error?.description || error?.type || "CalendarEvent/query failed"); } if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { return (response.methodResponses[1][1].list || []) as CalendarEvent[]; } return []; } async queryCalendarEvents( filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number ): Promise { try { const accountId = this.getCalendarsAccountId(); const queryArgs: Record = { accountId, filter, limit: limit || 1000, }; if (sort) { queryArgs.sort = sort; } const response = await this.request([ ["CalendarEvent/query", queryArgs, "0"], ["CalendarEvent/get", { accountId, "#ids": { resultOf: "0", name: "CalendarEvent/query", path: "/ids" }, }, "1"] ], this.calendarUsing()); if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") { return (response.methodResponses[1][1].list || []) as CalendarEvent[]; } return []; } catch (error) { console.error('Failed to query calendar events:', error); return []; } } async getCalendarEvent(id: string): Promise { try { const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["CalendarEvent/get", { accountId, ids: [id], }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") { const list = response.methodResponses[0][1].list || []; return list[0] || null; } return null; } catch (error) { console.error('Failed to get calendar event:', error); return null; } } async createCalendarEvent(event: Partial, sendSchedulingMessages?: boolean): Promise { const accountId = this.getCalendarsAccountId(); const setArgs: Record = { accountId, create: { "new-event": event } }; if (sendSchedulingMessages !== undefined) { setArgs.sendSchedulingMessages = sendSchedulingMessages; } const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (result.notCreated?.["new-event"]) { const error = result.notCreated["new-event"]; throw new Error(error.description || "Failed to create calendar event"); } const createdId = result.created?.["new-event"]?.id; if (createdId) { const created = await this.getCalendarEvent(createdId); if (created) return created; } } throw new Error("Failed to create calendar event"); } async updateCalendarEvent( eventId: string, updates: Partial, sendSchedulingMessages?: boolean ): Promise { const accountId = this.getCalendarsAccountId(); const setArgs: Record = { accountId, update: { [eventId]: updates } }; if (sendSchedulingMessages !== undefined) { setArgs.sendSchedulingMessages = sendSchedulingMessages; } const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (result.notUpdated?.[eventId]) { const error = result.notUpdated[eventId]; throw new Error(error.description || "Failed to update calendar event"); } return; } throw new Error("Failed to update calendar event"); } async parseCalendarEvents(accountId: string, blobId: string): Promise[]> { const response = await this.request([ ["CalendarEvent/parse", { accountId, blobIds: [blobId], }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/parse") { const result = response.methodResponses[0][1]; if (result.notParsable && result.notParsable.includes(blobId)) { throw new Error("Invalid calendar file format"); } if (result.notFound && result.notFound.includes(blobId)) { throw new Error("Uploaded file not found"); } const parsed = result.parsed?.[blobId]; if (parsed) { return Array.isArray(parsed) ? parsed : [parsed]; } return []; } throw new Error("Failed to parse calendar file"); } async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean): Promise { const accountId = this.getCalendarsAccountId(); const setArgs: Record = { accountId, destroy: [eventId] }; if (sendSchedulingMessages !== undefined) { setArgs.sendSchedulingMessages = sendSchedulingMessages; } const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (result.notDestroyed?.[eventId]) { const error = result.notDestroyed[eventId]; throw new Error(error.description || "Failed to delete calendar event"); } return; } throw new Error("Failed to delete calendar event"); } async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] }; const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["CalendarEvent/set", { accountId, destroy: eventIds }, "0"] ], this.calendarUsing()); const destroyed: string[] = []; const notDestroyed: string[] = []; if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (result.destroyed) destroyed.push(...result.destroyed); if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed)); } return { destroyed, notDestroyed }; } async downloadBlob(blobId: string, name?: string, type?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type); const response = await this.authenticatedFetch(url, {}); if (!response.ok) { throw new Error(`Failed to download attachment: ${response.status}`); } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = name || 'download'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } private pollingInterval: NodeJS.Timeout | null = null; private pollingStates: { [key: string]: string } = {}; private static readonly STATE_TYPE_MAP: Record = { 'Mailbox/get': 'Mailbox', 'Email/get': 'Email', 'Calendar/get': 'Calendar', 'CalendarEvent/get': 'CalendarEvent', 'SieveScript/get': 'SieveScript', }; // Polling-based push since EventSource cannot send Authorization headers setupPushNotifications(): boolean { this.fetchCurrentStates(); this.pollingInterval = setInterval(() => { this.checkForStateChanges(); }, 15_000); return true; } private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } { const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; const methodCalls: JMAPMethodCall[] = [ ['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'], ['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'], ]; if (this.supportsCalendars()) { using.push('urn:ietf:params:jmap:calendars'); const calAccountId = this.getCalendarsAccountId(); methodCalls.push( ['Calendar/get', { accountId: calAccountId, ids: null, properties: ['id'] }, 'c'], ['CalendarEvent/get', { accountId: calAccountId, ids: [], properties: ['id'] }, 'd'], ); } if (this.supportsSieve()) { using.push('urn:ietf:params:jmap:sieve'); methodCalls.push( ['SieveScript/get', { accountId: this.getSieveAccountId(), ids: [], properties: ['id'] }, 'e'], ); } return { using, methodCalls }; } private async fetchCurrentStates(): Promise { try { const { using, methodCalls } = this.buildStatePollingRequest(); const response = await this.authenticatedFetch(this.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ using, methodCalls }), }); if (response.ok) { const data = await response.json(); for (const [method, result] of data.methodResponses) { const stateKey = JMAPClient.STATE_TYPE_MAP[method]; if (stateKey && result.state) { this.pollingStates[stateKey] = result.state; } } } } catch { // Silently fail - polling will retry } } private async checkForStateChanges(): Promise { try { const { using, methodCalls } = this.buildStatePollingRequest(); const response = await this.authenticatedFetch(this.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ using, methodCalls }), }); if (response.ok) { const data = await response.json(); const changes: { [key: string]: string } = {}; let hasChanges = false; for (const [method, result] of data.methodResponses) { const stateKey = JMAPClient.STATE_TYPE_MAP[method]; if (stateKey && result.state) { if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) { changes[stateKey] = result.state; hasChanges = true; } this.pollingStates[stateKey] = 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 = {}; } onConnectionChange(callback: (connected: boolean) => void): void { this.connectionChangeCallback = callback; } onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; } getLastStates(): AccountStates { return { ...this.lastStates }; } setLastStates(states: AccountStates): void { this.lastStates = { ...states }; } }