Fixes Docker/reverse proxy deployments where the JMAP server returns its internal hostname in session URLs, causing all requests after login to fail.
2580 lines
78 KiB
TypeScript
2580 lines
78 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;
|
|
}
|
|
|
|
// 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();
|
|
|
|
this.rewriteSessionUrls(session);
|
|
|
|
// 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) {
|
|
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 {
|
|
// 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 rewriteSessionUrl(url: string): string {
|
|
try {
|
|
const urlOrigin = new URL(url).origin;
|
|
const serverOrigin = new URL(this.serverUrl).origin;
|
|
if (urlOrigin === serverOrigin) return url;
|
|
return serverOrigin + url.slice(urlOrigin.length);
|
|
} 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: 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");
|
|
}
|
|
|
|
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 fetch(url, {
|
|
headers: { 'Authorization': this.authHeader },
|
|
});
|
|
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 fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': this.authHeader,
|
|
'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 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 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 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'],
|
|
);
|
|
}
|
|
|
|
if (this.supportsSieve()) {
|
|
using.push('urn:ietf:params:jmap:sieve');
|
|
const sieveAccountId = this.getSieveAccountId();
|
|
methodCalls.push(
|
|
['SieveScript/get', { accountId: sieveAccountId, ids: [], properties: ['id'] }, 'e'],
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (method === 'SieveScript/get' && result.state) {
|
|
this.pollingStates['SieveScript'] = 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'],
|
|
);
|
|
}
|
|
|
|
if (this.supportsSieve()) {
|
|
using.push('urn:ietf:params:jmap:sieve');
|
|
const sieveAccountId = this.getSieveAccountId();
|
|
methodCalls.push(
|
|
['SieveScript/get', { accountId: sieveAccountId, ids: [], properties: ['id'] }, 'e'],
|
|
);
|
|
}
|
|
|
|
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',
|
|
'SieveScript/get': 'SieveScript',
|
|
};
|
|
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 };
|
|
}
|
|
} |