Files
SRCmail/lib/jmap/client.ts
T

2394 lines
72 KiB
TypeScript

import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
apiUrl: string;
downloadUrl: string;
uploadUrl?: string;
eventSourceUrl?: string;
primaryAccounts?: Record<string, string>;
accounts?: Record<string, JMAPAccount>;
capabilities?: Record<string, unknown>;
}
interface JMAPAccount {
name?: string;
isPersonal?: boolean;
isReadOnly?: boolean;
accountCapabilities?: Record<string, unknown>;
}
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<string, boolean>;
}
interface JMAPEmailHeader {
name: string;
value: string;
}
type JMAPMethodCall = [string, Record<string, unknown>, string];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type JMAPResponseResult = Record<string, any>;
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<string, boolean> = {};
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<string | null>;
private apiUrl: string = "";
private accountId: string = "";
private downloadUrl: string = "";
private capabilities: Record<string, unknown> = {};
private session: JMAPSession | null = null;
private lastPingTime: number = 0;
private pingInterval: NodeJS.Timeout | null = null;
private accounts: Record<string, JMAPAccount> = {};
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<string | null>,
): 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<typeof fetch>[1]): Promise<Response> {
const headers = { ...init?.headers as Record<string, string>, '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<string, string>, '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<string, string>, '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<void> {
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<void> {
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<void> {
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<void> {
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<JMAPResponse> {
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<Mailbox[]> {
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<Mailbox[]> {
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 getEmail(emailId: string, accountId?: string): Promise<Email | null> {
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<void> {
const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers');
let headersRecord: Record<string, string | string[]>;
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<string, string | string[]>;
}
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<void> {
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<void> {
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<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
update: {
[emailId]: {
"keywords/$flagged": starred,
},
},
}, "0"],
]);
}
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
update: {
[emailId]: {
keywords,
},
},
}, "0"],
]);
}
async deleteEmail(emailId: string): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
destroy: [emailId],
}, "0"],
]);
}
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.accountId;
await this.request([
["Email/set", {
accountId: targetAccountId,
update: {
[emailId]: {
mailboxIds: { [trashMailboxId]: true },
},
},
}, "0"],
]);
}
async batchDeleteEmails(emailIds: string[]): Promise<void> {
if (emailIds.length === 0) return;
await this.request([
["Email/set", {
accountId: this.accountId,
destroy: emailIds,
}, "0"],
]);
}
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<Mailbox> {
const createId = `new-${Date.now()}`;
const createData: Record<string, unknown> = { 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<void> {
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<void> {
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;
// Standard search: OR across all fields (from, to, subject, body)
// so the search bar finds matches in name, email address, subject, and body
const orFilter: Record<string, unknown> = {
operator: "OR",
conditions: [
{ from: query },
{ to: query },
{ subject: query },
{ body: query },
],
};
let filter: Record<string, unknown>;
if (mailboxId) {
filter = {
operator: "AND",
conditions: [
{ inMailbox: mailboxId },
orFilter,
],
};
} else {
filter = orFilter;
}
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<string, unknown>,
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<Thread | null> {
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<Email[]> {
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<Identity[]> {
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<Identity> {
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<void> {
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<void> {
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<VacationResponse> {
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<VacationResponse>): Promise<void> {
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<string> {
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<string, boolean>;
mailboxIds: Record<string, boolean>;
bodyValues: Record<string, { value: string }>;
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<void> {
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<string> {
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<string, unknown> {
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<SieveScript[]> {
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<string> {
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<string> {
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<SieveScript> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
const setArgs: Record<string, unknown> = {
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<void> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
const setArgs: Record<string, unknown> = {
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<void> {
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<void> {
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<void> {
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<AddressBook[]> {
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<ContactCard[]> {
try {
const accountId = this.getContactsAccountId();
const queryArgs: Record<string, unknown> = { 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<ContactCard | null> {
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<ContactCard>): Promise<ContactCard> {
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<ContactCard>): Promise<void> {
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<void> {
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<ContactCard[]> {
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<Calendar[]> {
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<Calendar>): Promise<Calendar> {
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<Calendar>): Promise<void> {
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<void> {
const accountId = this.getCalendarsAccountId();
const response = await this.request([
["Calendar/set", {
accountId,
destroy: [calendarId]
}, "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<CalendarEvent[]> {
try {
const accountId = this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = { 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());
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
}
return [];
} catch (error) {
console.error('Failed to get calendar events:', error);
return [];
}
}
async queryCalendarEvents(
filter: CalendarEventFilter,
sort?: Array<{ property: string; isAscending: boolean }>,
limit?: number
): Promise<CalendarEvent[]> {
try {
const accountId = this.getCalendarsAccountId();
const queryArgs: Record<string, unknown> = {
accountId,
filter,
limit: limit || 100,
};
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<CalendarEvent | null> {
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<CalendarEvent>, sendSchedulingMessages?: boolean): Promise<CalendarEvent> {
const accountId = this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
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<CalendarEvent>,
sendSchedulingMessages?: boolean
): Promise<void> {
const accountId = this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
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<Partial<CalendarEvent>[]> {
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<void> {
const accountId = this.getCalendarsAccountId();
const setArgs: Record<string, unknown> = {
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 downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
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<string, string> = {
'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<void> {
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<void> {
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 };
}
}