Files
SRCmail/lib/jmap/client.ts
T
Matthieu MALVACHEandMatthieu MALVACHE e6f67c24cb feat: add JMAP Calendar integration with month/week/day/agenda views
Full calendar support via JMAP Calendars (RFC 8984):
- Event create/edit/delete with recurrence rules and reminders
- Multi-day event spanning, column-based overlap layout
- Locale-aware date formatting, first day of week and time format settings
- Real-time updates via push notifications
- ARIA accessibility, input validation, color sanitization
- Keyboard shortcuts, mobile touch targets, focus trap
- ICU pluralization for all 8 supported languages
2026-02-16 20:34:29 +01:00

2275 lines
68 KiB
TypeScript

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