mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-26 08:01:20 +00:00
Add OAuth2 authorization code flow with PKCE as an alternative to basic auth. Supports Stalwart's built-in OAuth provider with automatic token refresh, session persistence via httpOnly cookies, and graceful fallback to basic auth when OAuth is not configured.
2230 lines
67 KiB
TypeScript
2230 lines
67 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 = {};
|
|
|
|
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 = await fetch(url, { ...init, headers });
|
|
|
|
if (response.status === 401 && 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 });
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
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();
|
|
} catch (error) {
|
|
console.error('Keep-alive ping failed:', error);
|
|
try {
|
|
await this.reconnect();
|
|
} 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): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
|
try {
|
|
const targetAccountId = accountId || this.accountId;
|
|
const filter: { inMailbox?: string } = {};
|
|
if (mailboxId) {
|
|
filter.inMailbox = mailboxId;
|
|
}
|
|
|
|
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): Promise<void> {
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
update: {
|
|
[emailId]: {
|
|
mailboxIds: { [toMailboxId]: true },
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
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 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;
|
|
const filter: Record<string, unknown> = { text: query };
|
|
if (mailboxId) {
|
|
filter.inMailbox = mailboxId;
|
|
}
|
|
|
|
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'));
|
|
}
|
|
|
|
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): Promise<SieveScript> {
|
|
const blobId = await this.uploadSieveBlob(content);
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", {
|
|
accountId,
|
|
create: {
|
|
"new-script": { name, blobId }
|
|
}
|
|
}, "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): Promise<void> {
|
|
const blobId = await this.uploadSieveBlob(content);
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", {
|
|
accountId,
|
|
update: {
|
|
[scriptId]: { blobId }
|
|
}
|
|
}, "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,
|
|
update: {
|
|
[scriptId]: { isActive: true }
|
|
}
|
|
}, "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 activate sieve script");
|
|
}
|
|
return;
|
|
}
|
|
throw new Error("Failed to activate sieve script");
|
|
}
|
|
|
|
async deactivateSieveScript(scriptId: string): Promise<void> {
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", {
|
|
accountId,
|
|
update: {
|
|
[scriptId]: { isActive: false }
|
|
}
|
|
}, "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 deactivate sieve script");
|
|
}
|
|
return;
|
|
}
|
|
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 = {};
|
|
}
|
|
|
|
onStateChange(callback: (change: StateChange) => void): void {
|
|
this.stateChangeCallback = callback;
|
|
}
|
|
|
|
getLastStates(): AccountStates {
|
|
return { ...this.lastStates };
|
|
}
|
|
|
|
setLastStates(states: AccountStates): void {
|
|
this.lastStates = { ...states };
|
|
}
|
|
} |