Add expandRecurrences: true to CalendarEvent/query when a date range filter is provided, so the JMAP server returns individual occurrences of recurring events instead of only the master event.
3651 lines
118 KiB
TypeScript
3651 lines
118 KiB
TypeScript
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, FileNode, FileNodeFilter } from "./types";
|
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
|
import { toWildcardQuery } from "./search-utils";
|
|
|
|
// JMAP protocol types - these are intentionally flexible due to server variations
|
|
interface JMAPSession {
|
|
apiUrl: string;
|
|
downloadUrl: string;
|
|
uploadUrl?: string;
|
|
eventSourceUrl?: string;
|
|
primaryAccounts?: Record<string, string>;
|
|
accounts?: Record<string, JMAPAccount>;
|
|
capabilities?: Record<string, unknown>;
|
|
}
|
|
|
|
interface JMAPAccount {
|
|
name?: string;
|
|
isPersonal?: boolean;
|
|
isReadOnly?: boolean;
|
|
accountCapabilities?: Record<string, unknown>;
|
|
}
|
|
|
|
interface JMAPQuota {
|
|
resourceType?: string;
|
|
scope?: string;
|
|
used?: number;
|
|
hardLimit?: number;
|
|
limit?: number;
|
|
}
|
|
|
|
interface JMAPMailbox {
|
|
id: string;
|
|
name: string;
|
|
parentId?: string | null;
|
|
role?: string | null;
|
|
totalEmails?: number;
|
|
unreadEmails?: number;
|
|
totalThreads?: number;
|
|
unreadThreads?: number;
|
|
sortOrder?: number;
|
|
isSubscribed?: boolean;
|
|
myRights?: Record<string, boolean>;
|
|
}
|
|
|
|
interface JMAPEmailHeader {
|
|
name: string;
|
|
value: string;
|
|
}
|
|
|
|
type JMAPMethodCall = [string, Record<string, unknown>, string];
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
type JMAPResponseResult = Record<string, any>;
|
|
|
|
interface JMAPResponse {
|
|
methodResponses: Array<[string, JMAPResponseResult, string]>;
|
|
}
|
|
|
|
const DEFAULT_MAILBOX_RIGHTS = {
|
|
mayReadItems: true,
|
|
mayAddItems: true,
|
|
mayRemoveItems: true,
|
|
maySetSeen: true,
|
|
maySetKeywords: true,
|
|
mayCreateChild: true,
|
|
mayRename: true,
|
|
mayDelete: true,
|
|
maySubmit: true,
|
|
} as const;
|
|
|
|
const EMAIL_LIST_PROPERTIES = [
|
|
"id",
|
|
"threadId",
|
|
"mailboxIds",
|
|
"keywords",
|
|
"size",
|
|
"receivedAt",
|
|
"from",
|
|
"to",
|
|
"cc",
|
|
"subject",
|
|
"preview",
|
|
"hasAttachment",
|
|
] as const;
|
|
|
|
function namespaceMailboxIds(emails: Email[], accountId: string): void {
|
|
for (const email of emails) {
|
|
if (!email.mailboxIds) continue;
|
|
const namespaced: Record<string, boolean> = {};
|
|
for (const mbId of Object.keys(email.mailboxIds)) {
|
|
namespaced[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
|
|
}
|
|
email.mailboxIds = namespaced;
|
|
}
|
|
}
|
|
|
|
function computeHasMore(position: number, emailCount: number, total: number, limit: number): boolean {
|
|
if (total > 0) return (position + emailCount) < total;
|
|
return emailCount === limit;
|
|
}
|
|
|
|
export class JMAPClient {
|
|
private serverUrl: string;
|
|
private username: string;
|
|
private password: string;
|
|
private authHeader: string;
|
|
private authMode: 'basic' | 'bearer' = 'basic';
|
|
private onTokenRefresh?: () => Promise<string | null>;
|
|
private apiUrl: string = "";
|
|
private accountId: string = "";
|
|
private downloadUrl: string = "";
|
|
private capabilities: Record<string, unknown> = {};
|
|
private session: JMAPSession | null = null;
|
|
private lastPingTime: number = 0;
|
|
private pingInterval: NodeJS.Timeout | null = null;
|
|
private accounts: Record<string, JMAPAccount> = {};
|
|
private eventSource: EventSource | null = null;
|
|
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
|
private lastStates: AccountStates = {};
|
|
private reconnecting = false;
|
|
private connectionChangeCallback: ((connected: boolean) => void) | null = null;
|
|
|
|
constructor(serverUrl: string, username: string, password: string) {
|
|
this.serverUrl = serverUrl.replace(/\/$/, '');
|
|
this.username = username;
|
|
this.password = password;
|
|
this.authHeader = `Basic ${btoa(`${username}:${password}`)}`;
|
|
}
|
|
|
|
static withBearer(
|
|
serverUrl: string,
|
|
accessToken: string,
|
|
username: string,
|
|
onTokenRefresh?: () => Promise<string | null>,
|
|
): JMAPClient {
|
|
const client = new JMAPClient(serverUrl, username, '');
|
|
client.authMode = 'bearer';
|
|
client.authHeader = `Bearer ${accessToken}`;
|
|
client.onTokenRefresh = onTokenRefresh;
|
|
return client;
|
|
}
|
|
|
|
updateAccessToken(token: string): void {
|
|
this.authHeader = `Bearer ${token}`;
|
|
}
|
|
|
|
getAuthHeader(): string {
|
|
return this.authHeader;
|
|
}
|
|
|
|
getServerUrl(): string {
|
|
return this.serverUrl;
|
|
}
|
|
|
|
private async authenticatedFetch(url: string, init?: Parameters<typeof fetch>[1]): Promise<Response> {
|
|
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
|
let response: Response;
|
|
|
|
try {
|
|
response = await fetch(url, { ...init, headers });
|
|
} catch (error) {
|
|
// Network error: retry once after brief delay (transient proxy/connection issues)
|
|
if (this.reconnecting) throw error;
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
response = await fetch(url, { ...init, headers });
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
if (this.authMode === 'bearer' && this.onTokenRefresh) {
|
|
const newToken = await this.onTokenRefresh();
|
|
if (newToken) {
|
|
this.updateAccessToken(newToken);
|
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
|
}
|
|
} else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) {
|
|
// JMAP session may have expired — re-establish and retry once
|
|
this.reconnecting = true;
|
|
try {
|
|
await this.refreshSession();
|
|
this.connectionChangeCallback?.(true);
|
|
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
|
response = await fetch(url, { ...init, headers: retryHeaders });
|
|
} catch {
|
|
// Session refresh failed — return original 401 response
|
|
} finally {
|
|
this.reconnecting = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private async refreshSession(): Promise<void> {
|
|
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
|
const response = await fetch(sessionUrl, {
|
|
method: 'GET',
|
|
headers: { 'Authorization': this.authHeader },
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Session refresh failed: ${response.status}`);
|
|
}
|
|
|
|
const session = await response.json();
|
|
this.rewriteSessionUrls(session);
|
|
this.session = session;
|
|
this.capabilities = session.capabilities || {};
|
|
this.apiUrl = session.apiUrl;
|
|
this.downloadUrl = session.downloadUrl;
|
|
this.accounts = session.accounts || {};
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
|
|
|
try {
|
|
const sessionResponse = await this.authenticatedFetch(sessionUrl, {
|
|
method: 'GET',
|
|
});
|
|
|
|
if (!sessionResponse.ok) {
|
|
if (sessionResponse.status === 401) {
|
|
throw new Error(this.authMode === 'bearer'
|
|
? 'Authentication failed - token may be expired'
|
|
: 'Invalid username or password');
|
|
}
|
|
throw new Error(`Failed to get session: ${sessionResponse.status}`);
|
|
}
|
|
|
|
const session = await sessionResponse.json();
|
|
this.rewriteSessionUrls(session);
|
|
|
|
this.session = session;
|
|
this.capabilities = session.capabilities || {};
|
|
this.apiUrl = session.apiUrl;
|
|
this.downloadUrl = session.downloadUrl;
|
|
this.accounts = session.accounts || {};
|
|
|
|
const mailAccount = session.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
|
const fallbackAccount = Object.keys(this.accounts)[0];
|
|
this.accountId = mailAccount || fallbackAccount;
|
|
|
|
if (!this.accountId) {
|
|
throw new Error('No mail account found in session');
|
|
}
|
|
|
|
this.startKeepAlive();
|
|
} catch (error) {
|
|
if (error instanceof TypeError && (error.message === 'Failed to fetch' || error.message.includes('NetworkError'))) {
|
|
let serverReachable = false;
|
|
try {
|
|
await fetch(sessionUrl, { mode: 'no-cors' });
|
|
serverReachable = true;
|
|
} catch { /* genuinely unreachable */ }
|
|
if (serverReachable) {
|
|
throw new Error('CORS_ERROR');
|
|
}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private startKeepAlive(): void {
|
|
this.stopKeepAlive();
|
|
|
|
this.pingInterval = setInterval(async () => {
|
|
try {
|
|
await this.ping();
|
|
this.connectionChangeCallback?.(true);
|
|
} catch (error) {
|
|
console.error('Keep-alive ping failed:', error);
|
|
this.connectionChangeCallback?.(false);
|
|
try {
|
|
await this.reconnect();
|
|
this.connectionChangeCallback?.(true);
|
|
} catch (reconnectError) {
|
|
console.error('Reconnection failed:', reconnectError);
|
|
}
|
|
}
|
|
}, 30_000);
|
|
}
|
|
|
|
private stopKeepAlive(): void {
|
|
if (this.pingInterval) {
|
|
clearInterval(this.pingInterval);
|
|
this.pingInterval = null;
|
|
}
|
|
}
|
|
|
|
async ping(): Promise<void> {
|
|
if (!this.apiUrl) {
|
|
throw new Error('Not connected');
|
|
}
|
|
|
|
const now = Date.now();
|
|
const response = await this.request([
|
|
["Core/echo", { ping: "pong" }, "0"]
|
|
]);
|
|
|
|
if (response.methodResponses?.[0]?.[0] !== "Core/echo") {
|
|
throw new Error('Ping failed');
|
|
}
|
|
this.lastPingTime = now;
|
|
}
|
|
|
|
async reconnect(): Promise<void> {
|
|
await this.connect();
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.stopKeepAlive();
|
|
this.closePushNotifications();
|
|
this.apiUrl = "";
|
|
this.accountId = "";
|
|
this.session = null;
|
|
this.capabilities = {};
|
|
}
|
|
|
|
private rewriteSessionUrl(url: string): string {
|
|
try {
|
|
const parsed = new URL(url);
|
|
const server = new URL(this.serverUrl);
|
|
if (parsed.origin === server.origin) return url;
|
|
const pathAndRest = url.slice(url.indexOf('/', url.indexOf('//') + 2));
|
|
return server.origin + pathAndRest;
|
|
} catch {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
private rewriteSessionUrls(session: JMAPSession): void {
|
|
session.apiUrl = this.rewriteSessionUrl(session.apiUrl);
|
|
session.downloadUrl = this.rewriteSessionUrl(session.downloadUrl);
|
|
if (session.uploadUrl) {
|
|
session.uploadUrl = this.rewriteSessionUrl(session.uploadUrl);
|
|
}
|
|
if (session.eventSourceUrl) {
|
|
session.eventSourceUrl = this.rewriteSessionUrl(session.eventSourceUrl);
|
|
}
|
|
}
|
|
|
|
private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
|
|
if (!this.apiUrl) {
|
|
throw new Error('Not connected. Call connect() first.');
|
|
}
|
|
|
|
const requestBody = {
|
|
using: using || ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"],
|
|
methodCalls,
|
|
};
|
|
|
|
const response = await this.authenticatedFetch(this.apiUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
const responseText = await response.text();
|
|
|
|
if (!response.ok) {
|
|
console.error('Request failed:', response.status, responseText);
|
|
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(responseText);
|
|
} catch {
|
|
console.error('Failed to parse response:', responseText);
|
|
throw new Error('Invalid JSON response from server');
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
async getQuota(): Promise<{ used: number; total: number } | null> {
|
|
try {
|
|
const response = await this.request([
|
|
["Quota/get", {
|
|
accountId: this.accountId,
|
|
}, "0"]
|
|
]);
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "Quota/get") {
|
|
const quotas = (response.methodResponses[0][1].list || []) as JMAPQuota[];
|
|
const mailQuota = quotas.find((q) => q.resourceType === "mail" || q.scope === "mail");
|
|
|
|
if (mailQuota) {
|
|
return {
|
|
used: mailQuota.used ?? 0,
|
|
total: mailQuota.hardLimit ?? mailQuota.limit ?? 0
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async getMailboxes(): Promise<Mailbox[]> {
|
|
try {
|
|
const response = await this.request([
|
|
["Mailbox/get", { accountId: this.accountId }, "0"]
|
|
]);
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
|
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
|
|
|
return rawMailboxes.map((mb) => ({
|
|
id: mb.id,
|
|
originalId: undefined,
|
|
name: mb.name,
|
|
parentId: mb.parentId || undefined,
|
|
role: mb.role || undefined,
|
|
sortOrder: mb.sortOrder ?? 0,
|
|
totalEmails: mb.totalEmails ?? 0,
|
|
unreadEmails: mb.unreadEmails ?? 0,
|
|
totalThreads: mb.totalThreads ?? 0,
|
|
unreadThreads: mb.unreadThreads ?? 0,
|
|
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
|
isSubscribed: mb.isSubscribed ?? true,
|
|
accountId: this.accountId,
|
|
accountName: this.accounts[this.accountId]?.name || this.username,
|
|
isShared: false,
|
|
}) as Mailbox);
|
|
}
|
|
|
|
throw new Error('Unexpected response format');
|
|
} catch (error) {
|
|
console.error('Failed to get mailboxes:', error);
|
|
return [{
|
|
id: 'INBOX',
|
|
originalId: undefined,
|
|
name: 'Inbox',
|
|
role: 'inbox',
|
|
sortOrder: 0,
|
|
totalEmails: 0,
|
|
unreadEmails: 0,
|
|
totalThreads: 0,
|
|
unreadThreads: 0,
|
|
myRights: DEFAULT_MAILBOX_RIGHTS,
|
|
isSubscribed: true,
|
|
accountId: this.accountId,
|
|
accountName: this.username,
|
|
isShared: false,
|
|
}] as Mailbox[];
|
|
}
|
|
}
|
|
|
|
async getAllMailboxes(): Promise<Mailbox[]> {
|
|
try {
|
|
const allMailboxes: Mailbox[] = [];
|
|
const accountIds = Object.keys(this.accounts);
|
|
|
|
if (accountIds.length === 0) {
|
|
return this.getMailboxes();
|
|
}
|
|
|
|
for (const accountId of accountIds) {
|
|
const account = this.accounts[accountId];
|
|
const isPrimary = accountId === this.accountId;
|
|
|
|
try {
|
|
const response = await this.request([
|
|
["Mailbox/get", {
|
|
accountId: accountId,
|
|
}, "0"]
|
|
]);
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
|
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
|
|
|
const mailboxes = rawMailboxes.map((mb) => ({
|
|
id: isPrimary ? mb.id : `${accountId}:${mb.id}`,
|
|
originalId: mb.id,
|
|
name: mb.name,
|
|
parentId: mb.parentId ? (isPrimary ? mb.parentId : `${accountId}:${mb.parentId}`) : undefined,
|
|
role: mb.role || undefined,
|
|
sortOrder: mb.sortOrder ?? 0,
|
|
totalEmails: mb.totalEmails ?? 0,
|
|
unreadEmails: mb.unreadEmails ?? 0,
|
|
totalThreads: mb.totalThreads ?? 0,
|
|
unreadThreads: mb.unreadThreads ?? 0,
|
|
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
|
isSubscribed: mb.isSubscribed ?? true,
|
|
accountId,
|
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
isShared: !isPrimary,
|
|
}) as Mailbox);
|
|
|
|
allMailboxes.push(...mailboxes);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
|
|
}
|
|
}
|
|
|
|
return allMailboxes;
|
|
} catch (error) {
|
|
console.error("Failed to fetch all mailboxes:", error);
|
|
return this.getMailboxes();
|
|
}
|
|
}
|
|
|
|
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
|
try {
|
|
const targetAccountId = accountId || this.accountId;
|
|
const filter: { inMailbox?: string; hasKeyword?: string } = {};
|
|
if (mailboxId) {
|
|
filter.inMailbox = mailboxId;
|
|
}
|
|
if (hasKeyword) {
|
|
filter.hasKeyword = hasKeyword;
|
|
}
|
|
|
|
const response = await this.request([
|
|
["Email/query", {
|
|
accountId: targetAccountId,
|
|
filter,
|
|
sort: [{ property: "receivedAt", isAscending: false }],
|
|
limit,
|
|
position,
|
|
}, "0"],
|
|
["Email/get", {
|
|
accountId: targetAccountId,
|
|
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
|
properties: [...EMAIL_LIST_PROPERTIES],
|
|
}, "1"],
|
|
]);
|
|
|
|
const queryResponse = response.methodResponses?.[0]?.[1];
|
|
const getResponse = response.methodResponses?.[1]?.[1];
|
|
|
|
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
|
|
const emails = getResponse.list || [];
|
|
const total = queryResponse?.total || 0;
|
|
const hasMore = computeHasMore(position, emails.length, total, limit);
|
|
|
|
if (accountId && accountId !== this.accountId) {
|
|
namespaceMailboxIds(emails, accountId);
|
|
}
|
|
|
|
return { emails, hasMore, total };
|
|
}
|
|
|
|
return { emails: [], hasMore: false, total: 0 };
|
|
} catch (error) {
|
|
console.error('Failed to get emails:', error);
|
|
return { emails: [], hasMore: false, total: 0 };
|
|
}
|
|
}
|
|
|
|
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
|
const allEmails: Email[] = [];
|
|
let position = 0;
|
|
const batchSize = 100;
|
|
|
|
// eslint-disable-next-line no-constant-condition
|
|
while (true) {
|
|
const { emails, hasMore } = await this.getEmails(mailboxId, undefined, batchSize, position);
|
|
allEmails.push(...emails);
|
|
if (!hasMore || emails.length === 0) break;
|
|
position += emails.length;
|
|
}
|
|
|
|
return allEmails;
|
|
}
|
|
|
|
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
|
if (tagIds.length === 0) return {};
|
|
try {
|
|
const methodCalls: JMAPMethodCall[] = [];
|
|
for (let i = 0; i < tagIds.length; i++) {
|
|
const keyword = `$label:${tagIds[i]}`;
|
|
// Total count for this tag
|
|
methodCalls.push(["Email/query", {
|
|
accountId: this.accountId,
|
|
filter: { hasKeyword: keyword },
|
|
limit: 0,
|
|
calculateTotal: true,
|
|
}, `total_${i}`]);
|
|
// Unread count for this tag
|
|
methodCalls.push(["Email/query", {
|
|
accountId: this.accountId,
|
|
filter: {
|
|
operator: "AND",
|
|
conditions: [
|
|
{ hasKeyword: keyword },
|
|
{ notKeyword: "$seen" },
|
|
],
|
|
},
|
|
limit: 0,
|
|
calculateTotal: true,
|
|
}, `unread_${i}`]);
|
|
}
|
|
|
|
const response = await this.request(methodCalls);
|
|
const result: Record<string, { total: number; unread: number }> = {};
|
|
|
|
for (let i = 0; i < tagIds.length; i++) {
|
|
const totalResp = response.methodResponses?.[i * 2]?.[1];
|
|
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
|
|
result[tagIds[i]] = {
|
|
total: totalResp?.total ?? 0,
|
|
unread: unreadResp?.total ?? 0,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Failed to get tag counts:', error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async getEmail(emailId: string, accountId?: string): Promise<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", "bodyStructure", "blobId",
|
|
],
|
|
fetchTextBodyValues: true,
|
|
fetchHTMLBodyValues: true,
|
|
fetchAllBodyValues: true,
|
|
maxBodyValueBytes: 256000,
|
|
}, "0"],
|
|
]);
|
|
|
|
if (response.methodResponses?.[0]?.[0] !== "Email/get") {
|
|
return null;
|
|
}
|
|
|
|
const email = (response.methodResponses[0][1].list || [])[0];
|
|
if (!email) return null;
|
|
|
|
if (accountId && accountId !== this.accountId) {
|
|
namespaceMailboxIds([email], accountId);
|
|
}
|
|
|
|
if (email.headers) {
|
|
await this.parseEmailHeaders(email);
|
|
}
|
|
|
|
return email;
|
|
} catch (error) {
|
|
console.error('Failed to get email:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private async parseEmailHeaders(email: Email): Promise<void> {
|
|
const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers');
|
|
|
|
let headersRecord: Record<string, string | string[]>;
|
|
if (Array.isArray(email.headers)) {
|
|
headersRecord = {};
|
|
for (const header of email.headers as unknown as JMAPEmailHeader[]) {
|
|
if (!header?.name || !header?.value) continue;
|
|
const existing = headersRecord[header.name];
|
|
if (existing) {
|
|
headersRecord[header.name] = Array.isArray(existing)
|
|
? [...existing, header.value]
|
|
: [existing, header.value];
|
|
} else {
|
|
headersRecord[header.name] = header.value;
|
|
}
|
|
}
|
|
email.headers = headersRecord;
|
|
} else {
|
|
headersRecord = email.headers as Record<string, string | string[]>;
|
|
}
|
|
|
|
const authResultsHeader = headersRecord['Authentication-Results'];
|
|
if (authResultsHeader) {
|
|
const value = Array.isArray(authResultsHeader) ? authResultsHeader[0] : authResultsHeader;
|
|
email.authenticationResults = parseAuthenticationResults(value);
|
|
}
|
|
|
|
for (const headerName of ['X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']) {
|
|
if (!headersRecord[headerName]) continue;
|
|
const value = Array.isArray(headersRecord[headerName]) ? headersRecord[headerName][0] : headersRecord[headerName];
|
|
const spamResult = parseSpamScore(value as string);
|
|
if (spamResult) {
|
|
email.spamScore = spamResult.score;
|
|
email.spamStatus = spamResult.status;
|
|
break;
|
|
}
|
|
}
|
|
|
|
const llmHeader = headersRecord['X-Spam-LLM'];
|
|
if (llmHeader) {
|
|
const value = Array.isArray(llmHeader) ? llmHeader[0] : llmHeader;
|
|
const llmResult = parseSpamLLM(value as string);
|
|
if (llmResult) {
|
|
email.spamLLM = llmResult;
|
|
}
|
|
}
|
|
}
|
|
|
|
async markAsRead(emailId: string, read: boolean = true, accountId?: string): Promise<void> {
|
|
const targetAccountId = accountId || this.accountId;
|
|
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: targetAccountId,
|
|
update: {
|
|
[emailId]: {
|
|
"keywords/$seen": read,
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
|
|
if (emailIds.length === 0) return;
|
|
|
|
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
|
|
await this.request([
|
|
["Email/set", { accountId: this.accountId, update: updates }, "0"],
|
|
]);
|
|
}
|
|
|
|
async toggleStar(emailId: string, starred: boolean): Promise<void> {
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
update: {
|
|
[emailId]: {
|
|
"keywords/$flagged": starred,
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
update: {
|
|
[emailId]: {
|
|
keywords,
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async deleteEmail(emailId: string): Promise<void> {
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
destroy: [emailId],
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void> {
|
|
const targetAccountId = accountId || this.accountId;
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: targetAccountId,
|
|
update: {
|
|
[emailId]: {
|
|
mailboxIds: { [trashMailboxId]: true },
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async batchDeleteEmails(emailIds: string[]): Promise<void> {
|
|
if (emailIds.length === 0) return;
|
|
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
destroy: emailIds,
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
|
|
if (emailIds.length === 0) return;
|
|
|
|
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }]));
|
|
await this.request([
|
|
["Email/set", { accountId: this.accountId, update: updates }, "0"],
|
|
]);
|
|
}
|
|
|
|
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
|
|
const targetAccountId = accountId || this.accountId;
|
|
const response = await this.request([
|
|
["Email/set", {
|
|
accountId: targetAccountId,
|
|
update: {
|
|
[emailId]: {
|
|
mailboxIds: { [toMailboxId]: true },
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
|
|
const result = response.methodResponses?.[0]?.[1];
|
|
if (result?.notUpdated?.[emailId]) {
|
|
throw new Error(`Failed to move email: ${result.notUpdated[emailId].type || 'unknown error'}`);
|
|
}
|
|
}
|
|
|
|
async emptyMailbox(mailboxId: string): Promise<number> {
|
|
let totalDestroyed = 0;
|
|
let hasMore = true;
|
|
|
|
while (hasMore) {
|
|
const response = await this.request([
|
|
["Email/query", {
|
|
accountId: this.accountId,
|
|
filter: { inMailbox: mailboxId },
|
|
limit: 500,
|
|
}, "0"],
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
"#destroy": { resultOf: "0", name: "Email/query", path: "/ids" },
|
|
}, "1"],
|
|
]);
|
|
|
|
const queryResult = response.methodResponses?.[0]?.[1];
|
|
const setResult = response.methodResponses?.[1]?.[1];
|
|
const destroyed = setResult?.destroyed?.length || 0;
|
|
totalDestroyed += destroyed;
|
|
|
|
hasMore = destroyed > 0 && (queryResult?.total || 0) > destroyed;
|
|
}
|
|
|
|
return totalDestroyed;
|
|
}
|
|
|
|
async markAsSpam(emailId: string, accountId?: string): Promise<void> {
|
|
const targetAccountId = accountId || this.accountId;
|
|
|
|
const mailboxes = await this.getMailboxes();
|
|
const junkMailbox = mailboxes.find(m => {
|
|
if (accountId) {
|
|
return m.role === 'junk' && m.accountId === accountId;
|
|
}
|
|
return m.role === 'junk' && !m.isShared;
|
|
});
|
|
|
|
if (!junkMailbox) {
|
|
throw new Error('Junk mailbox not found');
|
|
}
|
|
|
|
const mailboxId = accountId && junkMailbox.originalId
|
|
? junkMailbox.originalId
|
|
: junkMailbox.id;
|
|
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: targetAccountId,
|
|
update: {
|
|
[emailId]: {
|
|
mailboxIds: { [mailboxId]: true },
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
|
|
const targetAccountId = accountId || this.accountId;
|
|
|
|
await this.request([
|
|
["Email/set", {
|
|
accountId: targetAccountId,
|
|
update: {
|
|
[emailId]: {
|
|
mailboxIds: { [originalMailboxId]: true },
|
|
},
|
|
},
|
|
}, "0"],
|
|
]);
|
|
}
|
|
|
|
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
|
|
const createId = `new-${Date.now()}`;
|
|
const createData: Record<string, unknown> = { name };
|
|
if (parentId) {
|
|
createData.parentId = parentId;
|
|
}
|
|
|
|
const response = await this.request([
|
|
["Mailbox/set", {
|
|
accountId: this.accountId,
|
|
create: { [createId]: createData },
|
|
}, "0"],
|
|
]);
|
|
|
|
const result = response.methodResponses?.[0]?.[1];
|
|
if (result?.notCreated?.[createId]) {
|
|
throw new Error(`Failed to create mailbox: ${result.notCreated[createId].type || 'unknown error'}`);
|
|
}
|
|
|
|
const created = result?.created?.[createId];
|
|
if (!created?.id) {
|
|
throw new Error('Failed to create mailbox: no ID returned');
|
|
}
|
|
|
|
return {
|
|
id: created.id,
|
|
name,
|
|
parentId,
|
|
sortOrder: 0,
|
|
totalEmails: 0,
|
|
unreadEmails: 0,
|
|
totalThreads: 0,
|
|
unreadThreads: 0,
|
|
myRights: DEFAULT_MAILBOX_RIGHTS,
|
|
isSubscribed: true,
|
|
accountId: this.accountId,
|
|
accountName: this.accounts[this.accountId]?.name || this.username,
|
|
isShared: false,
|
|
};
|
|
}
|
|
|
|
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void> {
|
|
const response = await this.request([
|
|
["Mailbox/set", {
|
|
accountId: this.accountId,
|
|
update: { [mailboxId]: changes },
|
|
}, "0"],
|
|
]);
|
|
|
|
const result = response.methodResponses?.[0]?.[1];
|
|
if (result?.notUpdated?.[mailboxId]) {
|
|
throw new Error(`Failed to update mailbox: ${result.notUpdated[mailboxId].type || 'unknown error'}`);
|
|
}
|
|
}
|
|
|
|
async deleteMailbox(mailboxId: string): Promise<void> {
|
|
const response = await this.request([
|
|
["Mailbox/set", {
|
|
accountId: this.accountId,
|
|
destroy: [mailboxId],
|
|
}, "0"],
|
|
]);
|
|
|
|
const result = response.methodResponses?.[0]?.[1];
|
|
if (result?.notDestroyed?.[mailboxId]) {
|
|
throw new Error(`Failed to delete mailbox: ${result.notDestroyed[mailboxId].type || 'unknown error'}`);
|
|
}
|
|
}
|
|
|
|
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
|
try {
|
|
const targetAccountId = accountId || this.accountId;
|
|
|
|
// Use the JMAP "text" filter which searches across from, to, cc, bcc,
|
|
// subject, and body. Stalwart's FTS engine supports wildcard prefix
|
|
// matching (e.g. "pri*" matches "prime", "primary", "private", etc.)
|
|
const wildcardQuery = toWildcardQuery(query);
|
|
const textFilter: Record<string, unknown> = { text: wildcardQuery };
|
|
|
|
let filter: Record<string, unknown>;
|
|
if (mailboxId) {
|
|
filter = {
|
|
operator: "AND",
|
|
conditions: [
|
|
{ inMailbox: mailboxId },
|
|
textFilter,
|
|
],
|
|
};
|
|
} else {
|
|
filter = textFilter;
|
|
}
|
|
|
|
const response = await this.request([
|
|
["Email/query", {
|
|
accountId: targetAccountId,
|
|
filter,
|
|
sort: [{ property: "receivedAt", isAscending: false }],
|
|
limit,
|
|
position,
|
|
}, "0"],
|
|
["Email/get", {
|
|
accountId: targetAccountId,
|
|
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
|
properties: [...EMAIL_LIST_PROPERTIES],
|
|
}, "1"],
|
|
]);
|
|
|
|
const queryResponse = response.methodResponses?.[0]?.[1];
|
|
const emails = response.methodResponses?.[1]?.[1]?.list || [];
|
|
const total = queryResponse?.total || 0;
|
|
const hasMore = computeHasMore(position, emails.length, total, limit);
|
|
|
|
return { emails, hasMore, total };
|
|
} catch (error) {
|
|
console.error('Search failed:', error);
|
|
return { emails: [], hasMore: false, total: 0 };
|
|
}
|
|
}
|
|
|
|
async advancedSearchEmails(
|
|
filter: Record<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,
|
|
"textBody", "htmlBody", "bodyValues",
|
|
"attachments", "blobId", "sentAt", "bcc", "replyTo",
|
|
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
|
|
],
|
|
fetchTextBodyValues: true,
|
|
fetchHTMLBodyValues: true,
|
|
fetchAllBodyValues: true,
|
|
maxBodyValueBytes: 256000,
|
|
}, "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,
|
|
htmlBody?: string,
|
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number }>
|
|
): Promise<void> {
|
|
const emailId = `send-${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 target = fromEmail || this.username;
|
|
const matchingIdentity = identities.find((id) => id.email === target)
|
|
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
|
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always create a new email with the final body content
|
|
const emailCreate: Record<string, unknown> = {
|
|
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 },
|
|
};
|
|
|
|
if (htmlBody) {
|
|
// Send as multipart/alternative with both text and HTML
|
|
emailCreate.bodyValues = {
|
|
"text": { value: body },
|
|
"html": { value: htmlBody },
|
|
};
|
|
emailCreate.textBody = [{ partId: "text", type: "text/plain" }];
|
|
emailCreate.htmlBody = [{ partId: "html", type: "text/html" }];
|
|
} else {
|
|
emailCreate.bodyValues = { "1": { value: body } };
|
|
emailCreate.textBody = [{ partId: "1", type: "text/plain" }];
|
|
}
|
|
|
|
if (attachments?.length) {
|
|
emailCreate.attachments = attachments.map(att => ({
|
|
blobId: att.blobId,
|
|
type: att.type,
|
|
name: att.name,
|
|
disposition: "attachment",
|
|
}));
|
|
}
|
|
|
|
const methodCalls: JMAPMethodCall[] = [];
|
|
|
|
if (draftId) {
|
|
// Destroy the old draft and create a new email with the final body
|
|
methodCalls.push(["Email/set", {
|
|
accountId: this.accountId,
|
|
destroy: [draftId],
|
|
}, "0"]);
|
|
methodCalls.push(["Email/set", {
|
|
accountId: this.accountId,
|
|
create: { [emailId]: emailCreate },
|
|
}, "1"]);
|
|
methodCalls.push(["EmailSubmission/set", {
|
|
accountId: this.accountId,
|
|
create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
|
}, "2"]);
|
|
} else {
|
|
methodCalls.push(["Email/set", {
|
|
accountId: this.accountId,
|
|
create: { [emailId]: emailCreate },
|
|
}, "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) {
|
|
const errors = result.notCreated;
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP.
|
|
* This is needed when the server does not handle sendSchedulingMessages.
|
|
*/
|
|
async sendImipReply(opts: {
|
|
organizerEmail: string;
|
|
organizerName?: string;
|
|
attendeeEmail: string;
|
|
attendeeName?: string;
|
|
uid: string;
|
|
summary?: string;
|
|
dtStart?: string;
|
|
dtEnd?: string;
|
|
timeZone?: string;
|
|
isAllDay?: boolean;
|
|
sequence?: number;
|
|
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
|
identityId?: string;
|
|
}): Promise<void> {
|
|
const mailboxes = await this.getMailboxes();
|
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
|
if (!sentMailbox) {
|
|
throw new Error('No sent mailbox found');
|
|
}
|
|
|
|
let finalIdentityId = opts.identityId;
|
|
if (!finalIdentityId) {
|
|
const identityResponse = await this.request([
|
|
["Identity/get", { accountId: this.accountId }, "0"]
|
|
]);
|
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
|
const match = identities.find((id) => id.email === opts.attendeeEmail);
|
|
finalIdentityId = match?.id || identities[0]?.id || this.accountId;
|
|
} else {
|
|
finalIdentityId = this.accountId;
|
|
}
|
|
}
|
|
|
|
// Build iCalendar REPLY (RFC 5546 §3.2.3)
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
|
|
// Format a JSCalendar date string into iCalendar format
|
|
const formatIcalDate = (dateStr: string, tz?: string): string => {
|
|
// If it's an ISO UTC string (ends with Z), convert to iCalendar UTC format
|
|
if (dateStr.endsWith('Z')) {
|
|
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
}
|
|
// Local date-time: strip punctuation, keep as-is for TZID parameter
|
|
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
if (tz) {
|
|
return `TZID=${tz}:${basic}`;
|
|
}
|
|
return basic;
|
|
};
|
|
|
|
const lines: string[] = [
|
|
'BEGIN:VCALENDAR',
|
|
'PRODID:-//JMAP-Webmail//EN',
|
|
'VERSION:2.0',
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:REPLY',
|
|
'BEGIN:VEVENT',
|
|
`UID:${opts.uid}`,
|
|
`DTSTAMP:${now}`,
|
|
];
|
|
if (opts.dtStart) {
|
|
if (opts.isAllDay) {
|
|
// RFC 5545 §3.3.4: all-day events use VALUE=DATE (date-only, no time)
|
|
const dateOnly = opts.dtStart.replace(/[-]/g, '').substring(0, 8);
|
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
|
} else {
|
|
const formatted = formatIcalDate(opts.dtStart, opts.timeZone);
|
|
if (formatted.startsWith('TZID=')) {
|
|
lines.push(`DTSTART;${formatted}`);
|
|
} else {
|
|
lines.push(`DTSTART:${formatted}`);
|
|
}
|
|
}
|
|
}
|
|
if (opts.dtEnd) {
|
|
if (opts.isAllDay) {
|
|
const dateOnly = opts.dtEnd.replace(/[-]/g, '').substring(0, 8);
|
|
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
|
} else {
|
|
const formatted = formatIcalDate(opts.dtEnd, opts.timeZone);
|
|
if (formatted.startsWith('TZID=')) {
|
|
lines.push(`DTEND;${formatted}`);
|
|
} else {
|
|
lines.push(`DTEND:${formatted}`);
|
|
}
|
|
}
|
|
}
|
|
if (opts.summary) {
|
|
lines.push(`SUMMARY:${opts.summary}`);
|
|
}
|
|
if (opts.sequence != null) {
|
|
lines.push(`SEQUENCE:${opts.sequence}`);
|
|
}
|
|
const orgCn = opts.organizerName ? `;CN=${opts.organizerName}` : '';
|
|
lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`);
|
|
const attCn = opts.attendeeName ? `;CN=${opts.attendeeName}` : '';
|
|
lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`);
|
|
lines.push('END:VEVENT');
|
|
lines.push('END:VCALENDAR');
|
|
const icsContent = lines.join('\r\n') + '\r\n';
|
|
|
|
console.log('[iMIP DEBUG] Generated ICS:\n' + icsContent);
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
ACCEPTED: 'Accepted',
|
|
TENTATIVE: 'Tentative',
|
|
DECLINED: 'Declined',
|
|
};
|
|
const statusLabel = statusLabels[opts.status] || opts.status;
|
|
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
|
|
|
|
console.log('[iMIP DEBUG] identityId:', finalIdentityId);
|
|
|
|
const emailId = `imip-reply-${Date.now()}`;
|
|
const emailCreate: Record<string, unknown> = {
|
|
from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }],
|
|
to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }],
|
|
subject,
|
|
keywords: { "$seen": true },
|
|
mailboxIds: { [sentMailbox.id]: true },
|
|
bodyStructure: {
|
|
type: 'multipart/alternative',
|
|
subParts: [
|
|
{ partId: 'text', type: 'text/plain' },
|
|
{ partId: 'cal', type: 'text/calendar; method=REPLY' },
|
|
],
|
|
},
|
|
bodyValues: {
|
|
text: { value: `${opts.attendeeName || opts.attendeeEmail} has ${statusLabel.toLowerCase()} the invitation to: ${opts.summary || 'Event'}` },
|
|
cal: { value: icsContent },
|
|
},
|
|
};
|
|
|
|
const methodCalls: JMAPMethodCall[] = [
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
create: { [emailId]: emailCreate },
|
|
}, "0"],
|
|
["EmailSubmission/set", {
|
|
accountId: this.accountId,
|
|
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
|
}, "1"],
|
|
];
|
|
|
|
console.log('[iMIP DEBUG] Sending JMAP request with', methodCalls.length, 'method calls');
|
|
console.log('[iMIP DEBUG] Email create payload:', JSON.stringify(emailCreate, null, 2));
|
|
|
|
const response = await this.request(methodCalls);
|
|
|
|
console.log('[iMIP DEBUG] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
|
|
|
|
if (response.methodResponses) {
|
|
for (const [methodName, result] of response.methodResponses) {
|
|
if (methodName.endsWith('/error')) {
|
|
console.error('[iMIP DEBUG] method error:', methodName, result);
|
|
throw new Error(result.description || `iMIP reply failed: ${result.type}`);
|
|
}
|
|
if (result.notCreated) {
|
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
|
console.error('[iMIP DEBUG] create error:', JSON.stringify(result.notCreated, null, 2));
|
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply');
|
|
}
|
|
}
|
|
}
|
|
console.log('[iMIP DEBUG] sendImipReply completed successfully');
|
|
}
|
|
|
|
/**
|
|
* Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event.
|
|
* Used when creating or updating an event with participants.
|
|
*/
|
|
async sendImipInvitation(event: CalendarEvent): Promise<void> {
|
|
if (!event.participants) return;
|
|
|
|
const mailboxes = await this.getMailboxes();
|
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
|
if (!sentMailbox) {
|
|
throw new Error('No sent mailbox found');
|
|
}
|
|
|
|
// Find the organizer participant
|
|
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
|
|
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
|
|
const organizerName = organizerEntry?.name || '';
|
|
|
|
// Resolve identity
|
|
const identityResponse = await this.request([
|
|
["Identity/get", { accountId: this.accountId }, "0"]
|
|
]);
|
|
let identityId = this.accountId;
|
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
|
const match = identities.find((id) => id.email === organizerEmail);
|
|
identityId = match?.id || identities[0]?.id || this.accountId;
|
|
}
|
|
|
|
// Collect attendee participants (non-organizer)
|
|
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
|
|
if (attendees.length === 0) return;
|
|
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
|
|
const formatIcalDate = (dateStr: string, tz?: string | null): string => {
|
|
if (dateStr.endsWith('Z')) {
|
|
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
}
|
|
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
if (tz) return `TZID=${tz}:${basic}`;
|
|
return basic;
|
|
};
|
|
|
|
const lines: string[] = [
|
|
'BEGIN:VCALENDAR',
|
|
'PRODID:-//JMAP-Webmail//EN',
|
|
'VERSION:2.0',
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:REQUEST',
|
|
'BEGIN:VEVENT',
|
|
`UID:${event.uid}`,
|
|
`DTSTAMP:${now}`,
|
|
];
|
|
|
|
if (event.start) {
|
|
if (event.showWithoutTime) {
|
|
const dateOnly = event.start.replace(/[-]/g, '').substring(0, 8);
|
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
|
} else {
|
|
const formatted = formatIcalDate(event.start, event.timeZone);
|
|
lines.push(formatted.startsWith('TZID=') ? `DTSTART;${formatted}` : `DTSTART:${formatted}`);
|
|
}
|
|
}
|
|
|
|
if (event.utcEnd) {
|
|
if (event.showWithoutTime) {
|
|
const dateOnly = event.utcEnd.replace(/[-]/g, '').substring(0, 8);
|
|
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
|
} else {
|
|
const formatted = formatIcalDate(event.utcEnd, event.timeZone);
|
|
lines.push(formatted.startsWith('TZID=') ? `DTEND;${formatted}` : `DTEND:${formatted}`);
|
|
}
|
|
}
|
|
|
|
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
|
if (event.description) lines.push(`DESCRIPTION:${event.description}`);
|
|
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
|
if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`);
|
|
|
|
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
|
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
|
|
|
for (const attendee of attendees) {
|
|
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
|
if (!email) continue;
|
|
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
|
const partstat = attendee.participationStatus
|
|
? `;PARTSTAT=${attendee.participationStatus.toUpperCase()}`
|
|
: ';PARTSTAT=NEEDS-ACTION';
|
|
const rsvp = attendee.expectReply ? ';RSVP=TRUE' : '';
|
|
lines.push(`ATTENDEE${cn}${partstat}${rsvp}:mailto:${email}`);
|
|
}
|
|
|
|
lines.push('END:VEVENT');
|
|
lines.push('END:VCALENDAR');
|
|
const icsContent = lines.join('\r\n') + '\r\n';
|
|
|
|
const subject = `Invitation: ${event.title || 'Event'}`;
|
|
const toAddresses = attendees
|
|
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
|
|
.filter(a => a.email);
|
|
|
|
if (toAddresses.length === 0) return;
|
|
|
|
const emailId = `imip-invite-${Date.now()}`;
|
|
const emailCreate: Record<string, unknown> = {
|
|
from: [{ name: organizerName || undefined, email: organizerEmail }],
|
|
to: toAddresses,
|
|
subject,
|
|
keywords: { "$seen": true },
|
|
mailboxIds: { [sentMailbox.id]: true },
|
|
bodyStructure: {
|
|
type: 'multipart/alternative',
|
|
subParts: [
|
|
{ partId: 'text', type: 'text/plain' },
|
|
{ partId: 'cal', type: 'text/calendar; method=REQUEST' },
|
|
],
|
|
},
|
|
bodyValues: {
|
|
text: { value: `You have been invited to: ${event.title || 'Event'}` },
|
|
cal: { value: icsContent },
|
|
},
|
|
};
|
|
|
|
const methodCalls: JMAPMethodCall[] = [
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
create: { [emailId]: emailCreate },
|
|
}, "0"],
|
|
["EmailSubmission/set", {
|
|
accountId: this.accountId,
|
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
|
}, "1"],
|
|
];
|
|
|
|
const response = await this.request(methodCalls);
|
|
|
|
if (response.methodResponses) {
|
|
for (const [methodName, result] of response.methodResponses) {
|
|
if (methodName.endsWith('/error')) {
|
|
throw new Error(result.description || `iMIP invitation failed: ${result.type}`);
|
|
}
|
|
if (result.notCreated) {
|
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP invitation');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send an iMIP (RFC 6047) CANCEL email to all participants of a calendar event.
|
|
* Used when deleting an event that has participants.
|
|
*/
|
|
async sendImipCancellation(event: CalendarEvent): Promise<void> {
|
|
if (!event.participants) return;
|
|
|
|
const mailboxes = await this.getMailboxes();
|
|
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
|
if (!sentMailbox) {
|
|
throw new Error('No sent mailbox found');
|
|
}
|
|
|
|
const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner);
|
|
const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username;
|
|
const organizerName = organizerEntry?.name || '';
|
|
|
|
const identityResponse = await this.request([
|
|
["Identity/get", { accountId: this.accountId }, "0"]
|
|
]);
|
|
let identityId = this.accountId;
|
|
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
|
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
|
const match = identities.find((id) => id.email === organizerEmail);
|
|
identityId = match?.id || identities[0]?.id || this.accountId;
|
|
}
|
|
|
|
const attendees = Object.values(event.participants).filter(p => !p.roles?.owner);
|
|
if (attendees.length === 0) return;
|
|
|
|
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
|
|
const formatIcalDate = (dateStr: string, tz?: string | null): string => {
|
|
if (dateStr.endsWith('Z')) {
|
|
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
}
|
|
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
if (tz) return `TZID=${tz}:${basic}`;
|
|
return basic;
|
|
};
|
|
|
|
const lines: string[] = [
|
|
'BEGIN:VCALENDAR',
|
|
'PRODID:-//JMAP-Webmail//EN',
|
|
'VERSION:2.0',
|
|
'CALSCALE:GREGORIAN',
|
|
'METHOD:CANCEL',
|
|
'BEGIN:VEVENT',
|
|
`UID:${event.uid}`,
|
|
`DTSTAMP:${now}`,
|
|
`STATUS:CANCELLED`,
|
|
];
|
|
|
|
if (event.start) {
|
|
if (event.showWithoutTime) {
|
|
const dateOnly = event.start.replace(/[-]/g, '').substring(0, 8);
|
|
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
|
} else {
|
|
const formatted = formatIcalDate(event.start, event.timeZone);
|
|
lines.push(formatted.startsWith('TZID=') ? `DTSTART;${formatted}` : `DTSTART:${formatted}`);
|
|
}
|
|
}
|
|
|
|
if (event.title) lines.push(`SUMMARY:${event.title}`);
|
|
if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`);
|
|
|
|
const orgCn = organizerName ? `;CN=${organizerName}` : '';
|
|
lines.push(`ORGANIZER${orgCn}:mailto:${organizerEmail}`);
|
|
|
|
for (const attendee of attendees) {
|
|
const email = attendee.email || attendee.sendTo?.imip?.replace('mailto:', '');
|
|
if (!email) continue;
|
|
const cn = attendee.name ? `;CN=${attendee.name}` : '';
|
|
lines.push(`ATTENDEE${cn}:mailto:${email}`);
|
|
}
|
|
|
|
lines.push('END:VEVENT');
|
|
lines.push('END:VCALENDAR');
|
|
const icsContent = lines.join('\r\n') + '\r\n';
|
|
|
|
const subject = `Cancelled: ${event.title || 'Event'}`;
|
|
const toAddresses = attendees
|
|
.map(a => ({ name: a.name || undefined, email: a.email || a.sendTo?.imip?.replace('mailto:', '') || '' }))
|
|
.filter(a => a.email);
|
|
|
|
if (toAddresses.length === 0) return;
|
|
|
|
const emailId = `imip-cancel-${Date.now()}`;
|
|
const emailCreate: Record<string, unknown> = {
|
|
from: [{ name: organizerName || undefined, email: organizerEmail }],
|
|
to: toAddresses,
|
|
subject,
|
|
keywords: { "$seen": true },
|
|
mailboxIds: { [sentMailbox.id]: true },
|
|
bodyStructure: {
|
|
type: 'multipart/alternative',
|
|
subParts: [
|
|
{ partId: 'text', type: 'text/plain' },
|
|
{ partId: 'cal', type: 'text/calendar; method=CANCEL' },
|
|
],
|
|
},
|
|
bodyValues: {
|
|
text: { value: `The event "${event.title || 'Event'}" has been cancelled.` },
|
|
cal: { value: icsContent },
|
|
},
|
|
};
|
|
|
|
const methodCalls: JMAPMethodCall[] = [
|
|
["Email/set", {
|
|
accountId: this.accountId,
|
|
create: { [emailId]: emailCreate },
|
|
}, "0"],
|
|
["EmailSubmission/set", {
|
|
accountId: this.accountId,
|
|
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
|
|
}, "1"],
|
|
];
|
|
|
|
const response = await this.request(methodCalls);
|
|
|
|
if (response.methodResponses) {
|
|
for (const [methodName, result] of response.methodResponses) {
|
|
if (methodName.endsWith('/error')) {
|
|
throw new Error(result.description || `iMIP cancellation failed: ${result.type}`);
|
|
}
|
|
if (result.notCreated) {
|
|
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
|
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP cancellation');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
|
if (!this.session) {
|
|
throw new Error('Not connected. Call connect() first.');
|
|
}
|
|
|
|
const uploadUrl = this.session.uploadUrl;
|
|
if (!uploadUrl) {
|
|
throw new Error('Upload URL not available');
|
|
}
|
|
|
|
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId));
|
|
const response = await this.authenticatedFetch(finalUploadUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
body: file,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
|
|
}
|
|
|
|
const responseText = await response.text();
|
|
let result;
|
|
try {
|
|
result = JSON.parse(responseText);
|
|
} catch {
|
|
throw new Error('Invalid JSON response from upload');
|
|
}
|
|
|
|
// Direct format: { blobId, type, size }
|
|
if (result.blobId) {
|
|
return {
|
|
blobId: result.blobId,
|
|
size: result.size || file.size,
|
|
type: result.type || file.type,
|
|
};
|
|
}
|
|
|
|
// Nested format: { [accountId]: { blobId, type, size } }
|
|
const blobInfo = result[this.accountId];
|
|
if (blobInfo?.blobId) {
|
|
return {
|
|
blobId: blobInfo.blobId,
|
|
size: blobInfo.size || file.size,
|
|
type: blobInfo.type || file.type,
|
|
};
|
|
}
|
|
|
|
throw new Error('Invalid upload response: blobId not found');
|
|
}
|
|
|
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
|
|
if (!this.downloadUrl) {
|
|
throw new Error('Download URL not available. Please reconnect.');
|
|
}
|
|
|
|
// RFC 6570 level 1 URI template expansion
|
|
return this.downloadUrl
|
|
.replace('{accountId}', encodeURIComponent(this.accountId))
|
|
.replace('{blobId}', encodeURIComponent(blobId))
|
|
.replace('{name}', encodeURIComponent(name || 'download'))
|
|
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
|
}
|
|
|
|
async fetchBlob(blobId: string, name?: string, type?: string): Promise<Blob> {
|
|
const url = this.getBlobDownloadUrl(blobId, name, type);
|
|
const response = await this.authenticatedFetch(url, {});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch blob: ${response.status}`);
|
|
}
|
|
return response.blob();
|
|
}
|
|
|
|
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
|
|
const blob = await this.fetchBlob(blobId, name, type);
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
getCapabilities(): Record<string, unknown> {
|
|
return this.capabilities;
|
|
}
|
|
|
|
hasCapability(capability: string): boolean {
|
|
return capability in this.capabilities;
|
|
}
|
|
|
|
getMaxSizeUpload(): number {
|
|
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined;
|
|
return coreCapability?.maxSizeUpload || 0;
|
|
}
|
|
|
|
getMaxCallsInRequest(): number {
|
|
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxCallsInRequest?: number } | undefined;
|
|
return coreCapability?.maxCallsInRequest || 50;
|
|
}
|
|
|
|
getMaxObjectsInGet(): number {
|
|
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInGet?: number } | undefined;
|
|
return coreCapability?.maxObjectsInGet || 500;
|
|
}
|
|
|
|
getEventSourceUrl(): string | null {
|
|
if (!this.session) return null;
|
|
|
|
// RFC 8620: session root level, with fallback to capabilities for some servers
|
|
const coreCapability = this.session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined;
|
|
return this.session.eventSourceUrl || coreCapability?.eventSourceUrl || null;
|
|
}
|
|
|
|
getAccountId(): string {
|
|
return this.accountId;
|
|
}
|
|
|
|
getUsername(): string {
|
|
return this.username || this.session?.accounts?.[this.accountId]?.name || '';
|
|
}
|
|
|
|
supportsEmailSubmission(): boolean {
|
|
return this.hasCapability("urn:ietf:params:jmap:submission");
|
|
}
|
|
|
|
supportsQuota(): boolean {
|
|
return this.hasCapability("urn:ietf:params:jmap:quota");
|
|
}
|
|
|
|
supportsVacationResponse(): boolean {
|
|
return this.hasCapability("urn:ietf:params:jmap:vacationresponse");
|
|
}
|
|
|
|
supportsContacts(): boolean {
|
|
return this.hasCapability("urn:ietf:params:jmap:contacts");
|
|
}
|
|
|
|
supportsCalendars(): boolean {
|
|
return this.hasCapability("urn:ietf:params:jmap:calendars");
|
|
}
|
|
|
|
supportsSieve(): boolean {
|
|
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
|
}
|
|
|
|
getSieveAccountId(): string {
|
|
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
|
return sieveAccount || this.accountId;
|
|
}
|
|
|
|
private sieveUsing(): string[] {
|
|
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"];
|
|
}
|
|
|
|
getSieveCapabilities(): SieveCapabilities | null {
|
|
const sieveAccountId = this.getSieveAccountId();
|
|
const accountInfo = this.accounts[sieveAccountId];
|
|
if (!accountInfo?.accountCapabilities) return null;
|
|
const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"];
|
|
return (caps as SieveCapabilities) || null;
|
|
}
|
|
|
|
async getSieveScripts(): Promise<SieveScript[]> {
|
|
const response = await this.request([
|
|
["SieveScript/get", {
|
|
accountId: this.getSieveAccountId(),
|
|
}, "0"]
|
|
], this.sieveUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "SieveScript/get") {
|
|
return (response.methodResponses[0][1].list || []) as SieveScript[];
|
|
}
|
|
throw new Error('Failed to fetch Sieve scripts');
|
|
}
|
|
|
|
async getSieveScriptContent(blobId: string): Promise<string> {
|
|
const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve');
|
|
const response = await this.authenticatedFetch(url, {});
|
|
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
|
|
return response.text();
|
|
}
|
|
|
|
private async uploadSieveBlob(content: string): Promise<string> {
|
|
if (!this.session?.uploadUrl) {
|
|
throw new Error('Upload URL not available');
|
|
}
|
|
|
|
const uploadUrl = this.session.uploadUrl.replace(
|
|
'{accountId}',
|
|
encodeURIComponent(this.getSieveAccountId())
|
|
);
|
|
|
|
const response = await this.authenticatedFetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/sieve',
|
|
},
|
|
body: content,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to upload sieve script: ${response.status} - ${errorText.substring(0, 200)}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
if (result.blobId) return result.blobId;
|
|
const blobInfo = result[this.getSieveAccountId()];
|
|
if (blobInfo?.blobId) return blobInfo.blobId;
|
|
throw new Error('Invalid upload response: blobId not found');
|
|
}
|
|
|
|
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
|
|
const blobId = await this.uploadSieveBlob(content);
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const setArgs: Record<string, unknown> = {
|
|
accountId,
|
|
create: {
|
|
"new-script": { name, blobId }
|
|
},
|
|
};
|
|
if (activate) {
|
|
setArgs.onSuccessActivateScript = "#new-script";
|
|
}
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", setArgs, "0"]
|
|
], this.sieveUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
|
const result = response.methodResponses[0][1];
|
|
if (result.notCreated?.["new-script"]) {
|
|
const error = result.notCreated["new-script"];
|
|
throw new Error(error.description || "Failed to create sieve script");
|
|
}
|
|
const createdId = result.created?.["new-script"]?.id;
|
|
if (createdId) {
|
|
const scripts = await this.getSieveScripts();
|
|
const script = scripts.find(s => s.id === createdId);
|
|
if (script) return script;
|
|
}
|
|
}
|
|
throw new Error("Failed to create sieve script");
|
|
}
|
|
|
|
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
|
|
const blobId = await this.uploadSieveBlob(content);
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const setArgs: Record<string, unknown> = {
|
|
accountId,
|
|
update: {
|
|
[scriptId]: { blobId }
|
|
},
|
|
};
|
|
if (activate) {
|
|
setArgs.onSuccessActivateScript = scriptId;
|
|
}
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", setArgs, "0"]
|
|
], this.sieveUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
|
const result = response.methodResponses[0][1];
|
|
if (result.notUpdated?.[scriptId]) {
|
|
const error = result.notUpdated[scriptId];
|
|
throw new Error(error.description || "Failed to update sieve script");
|
|
}
|
|
return;
|
|
}
|
|
throw new Error("Failed to update sieve script");
|
|
}
|
|
|
|
async deleteSieveScript(scriptId: string): Promise<void> {
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", {
|
|
accountId,
|
|
destroy: [scriptId]
|
|
}, "0"]
|
|
], this.sieveUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
|
const result = response.methodResponses[0][1];
|
|
if (result.notDestroyed?.[scriptId]) {
|
|
const error = result.notDestroyed[scriptId];
|
|
throw new Error(error.description || "Failed to delete sieve script");
|
|
}
|
|
return;
|
|
}
|
|
throw new Error("Failed to delete sieve script");
|
|
}
|
|
|
|
async activateSieveScript(scriptId: string): Promise<void> {
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", {
|
|
accountId,
|
|
onSuccessActivateScript: scriptId,
|
|
}, "0"]
|
|
], this.sieveUsing());
|
|
|
|
const [methodName, result] = response.methodResponses?.[0] || [];
|
|
if (methodName === "error") {
|
|
throw new Error(result?.description || "Failed to activate sieve script");
|
|
}
|
|
if (methodName !== "SieveScript/set") {
|
|
throw new Error("Failed to activate sieve script");
|
|
}
|
|
}
|
|
|
|
async deactivateSieveScript(): Promise<void> {
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const response = await this.request([
|
|
["SieveScript/set", {
|
|
accountId,
|
|
onSuccessActivateScript: null,
|
|
}, "0"]
|
|
], this.sieveUsing());
|
|
|
|
const [methodName, result] = response.methodResponses?.[0] || [];
|
|
if (methodName === "error") {
|
|
throw new Error(result?.description || "Failed to deactivate sieve script");
|
|
}
|
|
if (methodName !== "SieveScript/set") {
|
|
throw new Error("Failed to deactivate sieve script");
|
|
}
|
|
}
|
|
|
|
async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
|
const blobId = await this.uploadSieveBlob(content);
|
|
const accountId = this.getSieveAccountId();
|
|
|
|
const response = await this.request([
|
|
["SieveScript/validate", {
|
|
accountId,
|
|
blobId,
|
|
}, "0"]
|
|
], this.sieveUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "SieveScript/validate") {
|
|
const result = response.methodResponses[0][1];
|
|
if (result.error) {
|
|
return { isValid: false, errors: [result.error.description || "Validation failed"] };
|
|
}
|
|
return { isValid: true };
|
|
}
|
|
|
|
if (response.methodResponses?.[0]?.[0]?.endsWith('/error')) {
|
|
const error = response.methodResponses[0][1];
|
|
return { isValid: false, errors: [error.description || "Validation failed"] };
|
|
}
|
|
|
|
return { isValid: false, errors: ['Unexpected validation response'] };
|
|
}
|
|
|
|
getContactsAccountId(): string {
|
|
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
|
|
return contactsAccount || this.accountId;
|
|
}
|
|
|
|
getCalendarsAccountId(): string {
|
|
const calendarsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:calendars"];
|
|
return calendarsAccount || this.accountId;
|
|
}
|
|
|
|
private contactUsing(): string[] {
|
|
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
|
}
|
|
|
|
private calendarUsing(): string[] {
|
|
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
|
}
|
|
|
|
private getCalendarCapableAccountIds(): string[] {
|
|
const primaryId = this.getCalendarsAccountId();
|
|
const accountIds: string[] = [];
|
|
for (const [id, account] of Object.entries(this.accounts)) {
|
|
if (id === primaryId) continue;
|
|
// Include accounts that either advertise calendar capability
|
|
// or are non-personal (shared/group) accounts — Stalwart doesn't
|
|
// always advertise capabilities on group accounts even when they
|
|
// have calendar resources.
|
|
if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"] || !account.isPersonal) {
|
|
accountIds.push(id);
|
|
}
|
|
}
|
|
return [primaryId, ...accountIds];
|
|
}
|
|
|
|
private getContactCapableAccountIds(): string[] {
|
|
const primaryId = this.getContactsAccountId();
|
|
const accountIds: string[] = [];
|
|
for (const [id, account] of Object.entries(this.accounts)) {
|
|
if (id === primaryId) continue;
|
|
// Include accounts that either advertise contacts capability
|
|
// or are non-personal (shared/group) accounts — Stalwart doesn't
|
|
// always advertise capabilities on group accounts even when they
|
|
// have contact resources.
|
|
if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"] || !account.isPersonal) {
|
|
accountIds.push(id);
|
|
}
|
|
}
|
|
return [primaryId, ...accountIds];
|
|
}
|
|
|
|
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 getAllAddressBooks(): Promise<AddressBook[]> {
|
|
try {
|
|
const allBooks: AddressBook[] = [];
|
|
const primaryId = this.getContactsAccountId();
|
|
const accountIds = this.getContactCapableAccountIds();
|
|
|
|
for (const accountId of accountIds) {
|
|
const isPrimary = accountId === primaryId;
|
|
const account = this.accounts[accountId];
|
|
|
|
try {
|
|
const response = await this.request([
|
|
["AddressBook/get", { accountId }, "0"]
|
|
], this.contactUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
|
const rawBooks = (response.methodResponses[0][1].list || []) as AddressBook[];
|
|
const books = rawBooks.map((book) => ({
|
|
...book,
|
|
id: isPrimary ? book.id : `${accountId}:${book.id}`,
|
|
originalId: book.id,
|
|
accountId,
|
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
isShared: !isPrimary,
|
|
}));
|
|
allBooks.push(...books);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to fetch address books for account ${accountId}:`, error);
|
|
}
|
|
}
|
|
|
|
return allBooks;
|
|
} catch (error) {
|
|
console.error('Failed to fetch all address books:', error);
|
|
return this.getAddressBooks();
|
|
}
|
|
}
|
|
|
|
private async fetchPaginatedContacts(
|
|
accountId: string,
|
|
filter?: Record<string, unknown>,
|
|
): Promise<ContactCard[]> {
|
|
const batchSize = this.getMaxObjectsInGet();
|
|
const allIds: string[] = [];
|
|
let position = 0;
|
|
|
|
// Paginate ContactCard/query to collect all IDs
|
|
for (;;) {
|
|
const queryArgs: Record<string, unknown> = { accountId, position, limit: batchSize };
|
|
if (filter) {
|
|
queryArgs.filter = filter;
|
|
}
|
|
|
|
const response = await this.request([
|
|
["ContactCard/query", queryArgs, "q"],
|
|
], this.contactUsing());
|
|
|
|
const queryResult = response.methodResponses?.[0];
|
|
if (queryResult?.[0] !== "ContactCard/query") break;
|
|
|
|
const ids: string[] = queryResult[1].ids || [];
|
|
allIds.push(...ids);
|
|
|
|
const total: number = queryResult[1].total ?? -1;
|
|
if (ids.length < batchSize || (total > 0 && allIds.length >= total)) {
|
|
break;
|
|
}
|
|
position += ids.length;
|
|
}
|
|
|
|
if (allIds.length === 0) return [];
|
|
|
|
// Batch ContactCard/get to respect maxObjectsInGet
|
|
const allContacts: ContactCard[] = [];
|
|
for (let i = 0; i < allIds.length; i += batchSize) {
|
|
const chunk = allIds.slice(i, i + batchSize);
|
|
const response = await this.request([
|
|
["ContactCard/get", { accountId, ids: chunk }, "g"],
|
|
], this.contactUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
|
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
|
|
allContacts.push(...list);
|
|
}
|
|
}
|
|
|
|
return allContacts;
|
|
}
|
|
|
|
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
|
try {
|
|
const accountId = this.getContactsAccountId();
|
|
const filter = addressBookId ? { inAddressBook: addressBookId } : undefined;
|
|
return await this.fetchPaginatedContacts(accountId, filter);
|
|
} catch (error) {
|
|
console.error('Failed to get contacts:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getAllContacts(): Promise<ContactCard[]> {
|
|
try {
|
|
const allContacts: ContactCard[] = [];
|
|
const primaryId = this.getContactsAccountId();
|
|
const accountIds = this.getContactCapableAccountIds();
|
|
|
|
for (const accountId of accountIds) {
|
|
const isPrimary = accountId === primaryId;
|
|
const account = this.accounts[accountId];
|
|
|
|
try {
|
|
const rawContacts = await this.fetchPaginatedContacts(accountId);
|
|
const contacts = rawContacts.map((contact) => ({
|
|
...contact,
|
|
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
|
originalId: contact.id,
|
|
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
|
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
|
) : contact.addressBookIds),
|
|
accountId,
|
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
isShared: !isPrimary,
|
|
}));
|
|
allContacts.push(...contacts);
|
|
} catch (error) {
|
|
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
|
}
|
|
}
|
|
|
|
return allContacts;
|
|
} catch (error) {
|
|
console.error('Failed to fetch all contacts:', error);
|
|
return this.getContacts();
|
|
}
|
|
}
|
|
|
|
async getContact(contactId: string, accountId?: string): Promise<ContactCard | null> {
|
|
try {
|
|
const targetAccountId = accountId || this.getContactsAccountId();
|
|
const response = await this.request([
|
|
["ContactCard/get", {
|
|
accountId: targetAccountId,
|
|
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>, targetAccountId?: string): Promise<ContactCard> {
|
|
const accountId = targetAccountId || 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 };
|
|
}
|
|
}
|
|
|
|
// Strip shared-only fields before sending to JMAP
|
|
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...contactData } = contact as ContactCard;
|
|
|
|
const response = await this.request([
|
|
["ContactCard/set", {
|
|
accountId,
|
|
create: {
|
|
"new-contact": {
|
|
...contactData,
|
|
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, accountId);
|
|
if (created) return created;
|
|
}
|
|
}
|
|
|
|
throw new Error("Failed to create contact");
|
|
}
|
|
|
|
async updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void> {
|
|
const accountId = targetAccountId || this.getContactsAccountId();
|
|
|
|
// Strip shared-only fields before sending to JMAP
|
|
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...cleanUpdates } = updates as ContactCard;
|
|
|
|
const response = await this.request([
|
|
["ContactCard/set", {
|
|
accountId,
|
|
update: {
|
|
[contactId]: cleanUpdates
|
|
}
|
|
}, "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, targetAccountId?: string): Promise<void> {
|
|
const accountId = targetAccountId || 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 allResults: ContactCard[] = [];
|
|
const primaryId = this.getContactsAccountId();
|
|
const accountIds = this.getContactCapableAccountIds();
|
|
|
|
for (const accountId of accountIds) {
|
|
const isPrimary = accountId === primaryId;
|
|
const account = this.accounts[accountId];
|
|
|
|
try {
|
|
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") {
|
|
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
|
const contacts = rawContacts.map((contact) => ({
|
|
...contact,
|
|
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
|
originalId: contact.id,
|
|
accountId,
|
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
isShared: !isPrimary,
|
|
}));
|
|
allResults.push(...contacts);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to search contacts for account ${accountId}:`, error);
|
|
}
|
|
}
|
|
|
|
return allResults;
|
|
} 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 getAllCalendars(): Promise<Calendar[]> {
|
|
try {
|
|
const allCalendars: Calendar[] = [];
|
|
const primaryId = this.getCalendarsAccountId();
|
|
const accountIds = this.getCalendarCapableAccountIds();
|
|
|
|
for (const accountId of accountIds) {
|
|
const isPrimary = accountId === primaryId;
|
|
const account = this.accounts[accountId];
|
|
|
|
try {
|
|
const response = await this.request([
|
|
["Calendar/get", { accountId }, "0"]
|
|
], this.calendarUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
|
const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
|
const calendars = rawCalendars.map((cal) => ({
|
|
...cal,
|
|
id: isPrimary ? cal.id : `${accountId}:${cal.id}`,
|
|
originalId: cal.id,
|
|
accountId,
|
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
isShared: !isPrimary,
|
|
}));
|
|
allCalendars.push(...calendars);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to fetch calendars for account ${accountId}:`, error);
|
|
}
|
|
}
|
|
|
|
return allCalendars;
|
|
} catch (error) {
|
|
console.error('Failed to fetch all calendars:', error);
|
|
return this.getCalendars();
|
|
}
|
|
}
|
|
|
|
async createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar> {
|
|
const accountId = targetAccountId || 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) {
|
|
// Fetch from the target account to find the created calendar
|
|
const fetchAccountId = targetAccountId || this.getCalendarsAccountId();
|
|
const fetchResponse = await this.request([
|
|
["Calendar/get", { accountId: fetchAccountId, ids: [createdId] }, "0"]
|
|
], this.calendarUsing());
|
|
if (fetchResponse.methodResponses?.[0]?.[0] === "Calendar/get") {
|
|
const list = fetchResponse.methodResponses[0][1].list || [];
|
|
if (list[0]) return list[0] as Calendar;
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error("Failed to create calendar");
|
|
}
|
|
|
|
async updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void> {
|
|
const accountId = targetAccountId || 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, targetAccountId?: string): Promise<void> {
|
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
|
|
|
const response = await this.request([
|
|
["Calendar/set", {
|
|
accountId,
|
|
destroy: [calendarId],
|
|
onDestroyRemoveEvents: true
|
|
}, "0"]
|
|
], this.calendarUsing());
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
|
const result = response.methodResponses[0][1];
|
|
|
|
if (result.notDestroyed?.[calendarId]) {
|
|
const error = result.notDestroyed[calendarId];
|
|
throw new Error(error.description || "Failed to delete calendar");
|
|
}
|
|
return;
|
|
}
|
|
|
|
throw new Error("Failed to delete calendar");
|
|
}
|
|
|
|
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
|
const accountId = targetAccountId || 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());
|
|
|
|
// Check for JMAP method-level errors
|
|
if (response.methodResponses?.[0]?.[0] === "error") {
|
|
const error = response.methodResponses[0][1];
|
|
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
|
}
|
|
|
|
if (response.methodResponses?.[1]?.[0] === "CalendarEvent/get") {
|
|
return (response.methodResponses[1][1].list || []) as CalendarEvent[];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async queryAllCalendarEvents(
|
|
filter: CalendarEventFilter,
|
|
sort?: Array<{ property: string; isAscending: boolean }>,
|
|
limit?: number
|
|
): Promise<CalendarEvent[]> {
|
|
try {
|
|
const allEvents: CalendarEvent[] = [];
|
|
const primaryId = this.getCalendarsAccountId();
|
|
const accountIds = this.getCalendarCapableAccountIds();
|
|
|
|
for (const accountId of accountIds) {
|
|
const isPrimary = accountId === primaryId;
|
|
const account = this.accounts[accountId];
|
|
|
|
try {
|
|
const events = await this.queryCalendarEvents(filter, sort, limit, accountId);
|
|
const mapped = events.map((event) => ({
|
|
...event,
|
|
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
|
originalId: event.id,
|
|
originalCalendarIds: event.calendarIds,
|
|
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
|
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
|
),
|
|
accountId,
|
|
accountName: account?.name || (isPrimary ? this.username : accountId),
|
|
isShared: !isPrimary,
|
|
}));
|
|
allEvents.push(...mapped);
|
|
} catch (error) {
|
|
console.error(`Failed to query calendar events for account ${accountId}:`, error);
|
|
}
|
|
}
|
|
|
|
return allEvents;
|
|
} catch (error) {
|
|
console.error('Failed to query all calendar events:', error);
|
|
return this.queryCalendarEvents(filter, sort, limit);
|
|
}
|
|
}
|
|
|
|
async queryCalendarEvents(
|
|
filter: CalendarEventFilter,
|
|
sort?: Array<{ property: string; isAscending: boolean }>,
|
|
limit?: number,
|
|
targetAccountId?: string
|
|
): Promise<CalendarEvent[]> {
|
|
try {
|
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
|
|
|
const queryArgs: Record<string, unknown> = {
|
|
accountId,
|
|
filter,
|
|
limit: limit || 1000,
|
|
};
|
|
// Expand recurring events into individual occurrences when a date range is provided
|
|
if (filter.after || filter.before) {
|
|
queryArgs.expandRecurrences = true;
|
|
}
|
|
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, targetAccountId?: string): Promise<CalendarEvent | null> {
|
|
try {
|
|
const accountId = targetAccountId || 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, targetAccountId?: string): Promise<CalendarEvent> {
|
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
|
|
|
// Strip client-only shared fields before sending to JMAP
|
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
|
|
|
const setArgs: Record<string, unknown> = {
|
|
accountId,
|
|
create: {
|
|
"new-event": cleanEvent
|
|
}
|
|
};
|
|
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, targetAccountId);
|
|
if (created) return created;
|
|
}
|
|
}
|
|
|
|
throw new Error("Failed to create calendar event");
|
|
}
|
|
|
|
async updateCalendarEvent(
|
|
eventId: string,
|
|
updates: Partial<CalendarEvent>,
|
|
sendSchedulingMessages?: boolean,
|
|
targetAccountId?: string
|
|
): Promise<void> {
|
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
|
|
|
// Strip client-only shared fields before sending to JMAP
|
|
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
|
|
|
const setArgs: Record<string, unknown> = {
|
|
accountId,
|
|
update: {
|
|
[eventId]: cleanUpdates
|
|
}
|
|
};
|
|
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];
|
|
console.log('[PARSE DEBUG] CalendarEvent/parse raw result:', JSON.stringify(result, null, 2));
|
|
|
|
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, targetAccountId?: string): Promise<void> {
|
|
const accountId = targetAccountId || 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 batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
|
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
|
|
|
const accountId = targetAccountId || this.getCalendarsAccountId();
|
|
const response = await this.request([
|
|
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
|
|
], this.calendarUsing());
|
|
|
|
const destroyed: string[] = [];
|
|
const notDestroyed: string[] = [];
|
|
|
|
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
|
const result = response.methodResponses[0][1];
|
|
if (result.destroyed) destroyed.push(...result.destroyed);
|
|
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
|
|
}
|
|
|
|
return { destroyed, notDestroyed };
|
|
}
|
|
|
|
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
|
|
|
|
supportsFiles(): boolean {
|
|
return this.hasCapability("urn:ietf:params:jmap:filenode");
|
|
}
|
|
|
|
async probeFileNodeSupport(): Promise<boolean> {
|
|
// Some servers support FileNode without advertising a specific capability.
|
|
// Try a minimal FileNode/query to detect support at runtime.
|
|
if (this.supportsFiles()) return true;
|
|
if (!this.apiUrl) return false;
|
|
try {
|
|
const accountId = this.getFilesAccountId();
|
|
const response = await this.authenticatedFetch(this.apiUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
using: ["urn:ietf:params:jmap:core"],
|
|
methodCalls: [["FileNode/query", { accountId, filter: {}, limit: 1 }, "probe0"]],
|
|
}),
|
|
});
|
|
if (!response.ok) return false;
|
|
const data = await response.json();
|
|
const result = data.methodResponses?.[0];
|
|
return result && result[0] === "FileNode/query";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getFilesAccountId(): string {
|
|
const filesAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:filenode"];
|
|
return filesAccount || this.accountId;
|
|
}
|
|
|
|
private fileUsing(): string[] {
|
|
const using = ["urn:ietf:params:jmap:core"];
|
|
if (this.hasCapability("urn:ietf:params:jmap:filenode")) {
|
|
using.push("urn:ietf:params:jmap:filenode");
|
|
}
|
|
return using;
|
|
}
|
|
|
|
private static FILE_NODE_PROPERTIES = ["id", "parentId", "name", "type", "blobId", "size", "created", "updated"];
|
|
|
|
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
|
|
const accountId = this.getFilesAccountId();
|
|
const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };
|
|
|
|
const response = await this.request(
|
|
[["FileNode/get", args, "fn0"]],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
const result = response.methodResponses?.[0];
|
|
if (!result || result[0] === "error") {
|
|
throw new Error(result?.[1]?.description || "FileNode/get failed");
|
|
}
|
|
return (result[1].list || []) as FileNode[];
|
|
}
|
|
|
|
async queryFileNodes(filter: FileNodeFilter, sort?: { property: string; isAscending: boolean }[]): Promise<string[]> {
|
|
const accountId = this.getFilesAccountId();
|
|
const args: Record<string, unknown> = { accountId, filter };
|
|
if (sort) args.sort = sort;
|
|
|
|
const response = await this.request(
|
|
[["FileNode/query", args, "fnq0"]],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
const result = response.methodResponses?.[0];
|
|
if (!result || result[0] === "error") {
|
|
throw new Error(result?.[1]?.description || "FileNode/query failed");
|
|
}
|
|
return (result[1].ids || []) as string[];
|
|
}
|
|
|
|
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
|
const accountId = this.getFilesAccountId();
|
|
const filter: Record<string, unknown> = {};
|
|
if (parentId !== null) {
|
|
filter.parentId = parentId;
|
|
}
|
|
// When parentId is null (root level), use empty filter to get all nodes.
|
|
// Stalwart's FileNode/query does not support parentId: null as a filter value.
|
|
|
|
const response = await this.request(
|
|
[
|
|
["FileNode/query", { accountId, filter }, "fnq0"],
|
|
["FileNode/get", { accountId, "#ids": { resultOf: "fnq0", name: "FileNode/query", path: "/ids" }, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"],
|
|
],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
// Check if query failed first
|
|
const queryResult = response.methodResponses?.find(r => r[0] === "FileNode/query" || (r[0] === "error" && r[2] === "fnq0"));
|
|
if (queryResult && queryResult[0] === "error") {
|
|
console.error('[Files] FileNode/query error:', queryResult[1]);
|
|
throw new Error(queryResult[1]?.description || "FileNode/query failed");
|
|
}
|
|
|
|
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fnq0"));
|
|
if (!getResult) {
|
|
console.error('[Files] No FileNode/get response. Full response:', JSON.stringify(response.methodResponses));
|
|
throw new Error("FileNode list failed - no response");
|
|
}
|
|
if (getResult[0] === "error") {
|
|
console.error('[Files] FileNode/get error:', getResult[1]);
|
|
throw new Error(getResult[1]?.description || "FileNode list failed");
|
|
}
|
|
const nodes = (getResult[1].list || []) as FileNode[];
|
|
// When listing root, filter client-side to only show root-level items
|
|
if (parentId === null) {
|
|
return nodes.filter(n => n.parentId === null);
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
|
const accountId = this.getFilesAccountId();
|
|
|
|
// Stalwart requires a blobId even for directories — upload an empty blob
|
|
const emptyBlob = new File([], name, { type: 'application/x-directory' });
|
|
const { blobId } = await this.uploadBlob(emptyBlob);
|
|
|
|
const dirProps: Record<string, unknown> = { name, type: "d", blobId, size: 0 };
|
|
if (parentId !== null) {
|
|
dirProps.parentId = parentId;
|
|
}
|
|
|
|
const response = await this.request(
|
|
[["FileNode/set", {
|
|
accountId,
|
|
create: {
|
|
dir0: dirProps,
|
|
},
|
|
}, "fns0"]],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
const result = response.methodResponses?.[0];
|
|
if (!result || result[0] === "error") {
|
|
throw new Error(result?.[1]?.description || "FileNode/set create failed");
|
|
}
|
|
const created = result[1].created?.dir0;
|
|
if (!created) {
|
|
const err = result[1].notCreated?.dir0;
|
|
throw new Error(err?.description || "Failed to create directory");
|
|
}
|
|
return created as FileNode;
|
|
}
|
|
|
|
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
|
const accountId = this.getFilesAccountId();
|
|
|
|
const fileProps: Record<string, unknown> = { name, type, blobId, size };
|
|
if (parentId !== null) {
|
|
fileProps.parentId = parentId;
|
|
}
|
|
|
|
const response = await this.request(
|
|
[["FileNode/set", {
|
|
accountId,
|
|
create: {
|
|
file0: fileProps,
|
|
},
|
|
}, "fns0"]],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
const result = response.methodResponses?.[0];
|
|
if (!result || result[0] === "error") {
|
|
throw new Error(result?.[1]?.description || "FileNode/set create failed");
|
|
}
|
|
const created = result[1].created?.file0;
|
|
if (!created) {
|
|
const err = result[1].notCreated?.file0;
|
|
throw new Error(err?.description || "Failed to create file node");
|
|
}
|
|
return created as FileNode;
|
|
}
|
|
|
|
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
|
const accountId = this.getFilesAccountId();
|
|
|
|
const response = await this.request(
|
|
[["FileNode/set", {
|
|
accountId,
|
|
update: { [id]: updates },
|
|
}, "fns0"]],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
const result = response.methodResponses?.[0];
|
|
if (!result || result[0] === "error") {
|
|
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
|
}
|
|
if (result[1].notUpdated?.[id]) {
|
|
throw new Error(result[1].notUpdated[id].description || "Failed to update file node");
|
|
}
|
|
}
|
|
|
|
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
|
const accountId = this.getFilesAccountId();
|
|
|
|
const response = await this.request(
|
|
[["FileNode/set", {
|
|
accountId,
|
|
destroy: ids,
|
|
}, "fns0"]],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
const result = response.methodResponses?.[0];
|
|
if (!result || result[0] === "error") {
|
|
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
|
}
|
|
return {
|
|
destroyed: result[1].destroyed || [],
|
|
notDestroyed: result[1].notDestroyed ? Object.keys(result[1].notDestroyed) : [],
|
|
};
|
|
}
|
|
|
|
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
|
// Copy: get original, upload blob reference, create new node
|
|
const nodes = await this.getFileNodes([id]);
|
|
if (nodes.length === 0) throw new Error('File node not found');
|
|
const original = nodes[0];
|
|
|
|
const accountId = this.getFilesAccountId();
|
|
const createProps: Record<string, unknown> = {
|
|
name: newName,
|
|
type: original.type,
|
|
blobId: original.blobId,
|
|
size: original.size,
|
|
};
|
|
if (parentId !== null) {
|
|
createProps.parentId = parentId;
|
|
}
|
|
|
|
const response = await this.request(
|
|
[["FileNode/set", {
|
|
accountId,
|
|
create: {
|
|
copy0: createProps,
|
|
},
|
|
}, "fns0"]],
|
|
this.fileUsing(),
|
|
);
|
|
|
|
const result = response.methodResponses?.[0];
|
|
if (!result || result[0] === "error") {
|
|
throw new Error(result?.[1]?.description || "FileNode copy failed");
|
|
}
|
|
const created = result[1].created?.copy0;
|
|
if (!created) {
|
|
const err = result[1].notCreated?.copy0;
|
|
throw new Error(err?.description || "Failed to copy file node");
|
|
}
|
|
return created as FileNode;
|
|
}
|
|
|
|
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
|
const blob = await this.fetchBlob(blobId, name, type);
|
|
const blobUrl = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = blobUrl;
|
|
a.download = name || 'download';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(blobUrl);
|
|
}
|
|
|
|
private pollingInterval: NodeJS.Timeout | null = null;
|
|
private pollingStates: { [key: string]: string } = {};
|
|
|
|
private static readonly STATE_TYPE_MAP: Record<string, string> = {
|
|
'Mailbox/get': 'Mailbox',
|
|
'Email/get': 'Email',
|
|
'Calendar/get': 'Calendar',
|
|
'CalendarEvent/get': 'CalendarEvent',
|
|
'SieveScript/get': 'SieveScript',
|
|
};
|
|
|
|
// Polling-based push since EventSource cannot send Authorization headers
|
|
setupPushNotifications(): boolean {
|
|
this.fetchCurrentStates();
|
|
this.pollingInterval = setInterval(() => {
|
|
this.checkForStateChanges();
|
|
}, 15_000);
|
|
return true;
|
|
}
|
|
|
|
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
|
|
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
|
const methodCalls: JMAPMethodCall[] = [
|
|
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
|
|
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
|
|
];
|
|
|
|
if (this.supportsCalendars()) {
|
|
using.push('urn:ietf:params:jmap:calendars');
|
|
const calAccountId = this.getCalendarsAccountId();
|
|
methodCalls.push(
|
|
['Calendar/get', { accountId: calAccountId, ids: null, properties: ['id'] }, 'c'],
|
|
['CalendarEvent/get', { accountId: calAccountId, ids: [], properties: ['id'] }, 'd'],
|
|
);
|
|
}
|
|
|
|
if (this.supportsSieve()) {
|
|
using.push('urn:ietf:params:jmap:sieve');
|
|
methodCalls.push(
|
|
['SieveScript/get', { accountId: this.getSieveAccountId(), ids: [], properties: ['id'] }, 'e'],
|
|
);
|
|
}
|
|
|
|
return { using, methodCalls };
|
|
}
|
|
|
|
private async fetchCurrentStates(): Promise<void> {
|
|
try {
|
|
const { using, methodCalls } = this.buildStatePollingRequest();
|
|
const response = await this.authenticatedFetch(this.apiUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ using, methodCalls }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
for (const [method, result] of data.methodResponses) {
|
|
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
|
|
if (stateKey && result.state) {
|
|
this.pollingStates[stateKey] = result.state;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently fail - polling will retry
|
|
}
|
|
}
|
|
|
|
private async checkForStateChanges(): Promise<void> {
|
|
try {
|
|
const { using, methodCalls } = this.buildStatePollingRequest();
|
|
const response = await this.authenticatedFetch(this.apiUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ using, methodCalls }),
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const changes: { [key: string]: string } = {};
|
|
let hasChanges = false;
|
|
|
|
for (const [method, result] of data.methodResponses) {
|
|
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
|
|
if (stateKey && result.state) {
|
|
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
|
|
changes[stateKey] = result.state;
|
|
hasChanges = true;
|
|
}
|
|
this.pollingStates[stateKey] = result.state;
|
|
}
|
|
}
|
|
|
|
if (hasChanges && this.stateChangeCallback) {
|
|
this.stateChangeCallback({
|
|
'@type': 'StateChange',
|
|
changed: { [this.accountId]: changes },
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently fail - polling will retry
|
|
}
|
|
}
|
|
|
|
closePushNotifications(): void {
|
|
if (this.pollingInterval) {
|
|
clearInterval(this.pollingInterval);
|
|
this.pollingInterval = null;
|
|
}
|
|
if (this.eventSource) {
|
|
this.eventSource.close();
|
|
this.eventSource = null;
|
|
}
|
|
this.stateChangeCallback = null;
|
|
this.pollingStates = {};
|
|
}
|
|
|
|
onConnectionChange(callback: (connected: boolean) => void): void {
|
|
this.connectionChangeCallback = callback;
|
|
}
|
|
|
|
onStateChange(callback: (change: StateChange) => void): void {
|
|
this.stateChangeCallback = callback;
|
|
}
|
|
|
|
getLastStates(): AccountStates {
|
|
return { ...this.lastStates };
|
|
}
|
|
|
|
setLastStates(states: AccountStates): void {
|
|
this.lastStates = { ...states };
|
|
}
|
|
|
|
// ── S/MIME raw-email helpers ─────────────────────────────────────
|
|
|
|
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
|
|
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
|
const url = this.getBlobDownloadUrl(blobId, name, type);
|
|
const response = await this.authenticatedFetch(url, {});
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch blob: ${response.status}`);
|
|
}
|
|
return response.arrayBuffer();
|
|
}
|
|
|
|
/** Import a raw MIME message blob into the account. */
|
|
async importRawEmail(
|
|
blob: Blob,
|
|
mailboxIds: Record<string, boolean>,
|
|
keywords?: Record<string, boolean>,
|
|
): Promise<string> {
|
|
// First upload the blob
|
|
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
|
const { blobId } = await this.uploadBlob(file);
|
|
|
|
// Then import via Email/import
|
|
const response = await this.request([
|
|
['Email/import', {
|
|
accountId: this.accountId,
|
|
emails: {
|
|
'smime-import': {
|
|
blobId,
|
|
mailboxIds,
|
|
keywords: keywords ?? { '$seen': true },
|
|
},
|
|
},
|
|
}, '0'],
|
|
]);
|
|
|
|
const importResult = response.methodResponses?.[0]?.[1];
|
|
if (importResult?.notCreated?.['smime-import']) {
|
|
const err = importResult.notCreated['smime-import'];
|
|
throw new Error(err.description || err.type || 'Failed to import email');
|
|
}
|
|
|
|
const emailId = importResult?.created?.['smime-import']?.id;
|
|
if (!emailId) {
|
|
throw new Error('Email import succeeded but no ID returned');
|
|
}
|
|
return emailId;
|
|
}
|
|
|
|
/** Submit an already-imported email for delivery. */
|
|
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
|
const response = await this.request([
|
|
['EmailSubmission/set', {
|
|
accountId: this.accountId,
|
|
create: { 'smime-submit': { emailId, identityId } },
|
|
}, '0'],
|
|
]);
|
|
|
|
const result = response.methodResponses?.[0]?.[1];
|
|
if (result?.notCreated?.['smime-submit']) {
|
|
const err = result.notCreated['smime-submit'];
|
|
throw new Error(err.description || err.type || 'Failed to submit email');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import a raw S/MIME message, move it to the Sent mailbox, and submit it.
|
|
* Encapsulates the full import → update → submit flow.
|
|
*/
|
|
async sendRawEmail(
|
|
blob: Blob,
|
|
identityId: string,
|
|
sentMailboxId: string,
|
|
draftMailboxId?: string,
|
|
): Promise<void> {
|
|
// Upload the raw message
|
|
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
|
const { blobId } = await this.uploadBlob(file);
|
|
|
|
// Import into Sent, mark as seen, and submit — all in one request
|
|
const methodCalls: [string, Record<string, unknown>, string][] = [
|
|
['Email/import', {
|
|
accountId: this.accountId,
|
|
emails: {
|
|
'raw-import': {
|
|
blobId,
|
|
mailboxIds: { [sentMailboxId]: true },
|
|
keywords: { '$seen': true },
|
|
},
|
|
},
|
|
}, '0'],
|
|
['EmailSubmission/set', {
|
|
accountId: this.accountId,
|
|
create: {
|
|
'raw-submit': {
|
|
emailId: '#raw-import',
|
|
identityId,
|
|
},
|
|
},
|
|
}, '1'],
|
|
];
|
|
|
|
const response = await this.request(methodCalls);
|
|
|
|
// Check for errors
|
|
for (const [methodName, result] of response.methodResponses ?? []) {
|
|
if (methodName.endsWith('/error')) {
|
|
throw new Error((result as { description?: string }).description || `Failed: ${(result as { type?: string }).type}`);
|
|
}
|
|
const r = result as { notCreated?: Record<string, { description?: string; type?: string }> };
|
|
if (r.notCreated) {
|
|
const firstErr = Object.values(r.notCreated)[0];
|
|
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
|
}
|
|
}
|
|
}
|
|
} |