import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; import { batched, itemsPerRequest } from "./request-limits"; import { noteTransportFailure, noteTransportSuccess, transportFailureCount } from "./transport-health"; import { debug } from "@/lib/debug"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; export class TransportError extends Error { constructor(message = 'Network transport failure') { super(message); this.name = 'TransportError'; } } function wasTransportFailure(beforeCount: number): boolean { return transportFailureCount() > beforeCount; } /** Parse a recipient string that may be "Name " or bare "email" into { name?, email }. */ function parseRecipientString(s: string): { name?: string; email: string } { const trimmed = s.trim(); const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/); if (angleMatch) { return { name: angleMatch[1].trim(), email: angleMatch[2].trim() }; } return { email: trimmed }; } /** * Build the `mailboxIds` portion of an `Email/set` PatchObject as a full-property * replacement — `{ mailboxIds: { : true, ... } }` — instead of per-id * `mailboxIds/` JSON-Pointer patches. * * Two reasons: * 1. It states the actual intent of a post-send / undo-send move: the message * should belong to *exactly* the given mailbox(es). * 2. It avoids per-id JSON-Pointer tokens entirely. Stalwart (observed on * 0.15.5) rejects an `Email/set` PatchObject whose pointer token is a * purely-numeric string — e.g. `mailboxIds/0` for a mailbox whose JMAP id is * "0" — with `invalidProperties: "Invalid patch value"` (it treats the digits * as a JSON-Pointer array index even though `mailboxIds` is a JSON object; * cf. RFC 6901 §4, and RFC 8620 §1.2's warning against interop-hostile ids). * That silently stranded already-delivered mail in Drafts for accounts whose * Drafts/Sent mailbox id happened to be all digits (a full member of `0`, * `1`, … `9`, `10`, … was verified rejected; ids containing a letter work). * Stalwart fixed the parsing in 0.16.5 (stalwartlabs/stalwart@175f34ea, * jmap-tools 0.1.5), but earlier deployments remain in the wild — and not * emitting interop-hostile pointer tokens is the safer shape regardless. * * This is a *replacement*: it drops any other mailbox membership the message * had, so callers must know the complete target set. Do NOT also place a * `mailboxIds/` pointer key in the same PatchObject — a pointer whose prefix * is another key in the object is illegal (RFC 8620 §5.3). */ function mailboxIdsReplacement( mailboxId: string, ...moreMailboxIds: string[] ): { mailboxIds: Record } { const mailboxIds: Record = { [mailboxId]: true }; for (const id of moreMailboxIds) mailboxIds[id] = true; return { mailboxIds }; } export class RateLimitError extends Error { retryAfterMs: number; constructor(retryAfterMs: number) { super('Rate limited by server'); this.name = 'RateLimitError'; this.retryAfterMs = retryAfterMs; } } // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { // The authenticated login (JMAP spec Session.username) — server-confirmed, // unlike the client-side constructor username or the sending identity. username?: string; apiUrl: string; downloadUrl: string; uploadUrl?: string; eventSourceUrl?: string; primaryAccounts?: Record; accounts?: Record; capabilities?: Record; } interface JMAPAccount { name?: string; isPersonal?: boolean; isReadOnly?: boolean; accountCapabilities?: Record; } interface JMAPQuota { resourceType?: string; scope?: string; types?: 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; } interface JMAPEmailHeader { name: string; value: string; } type JMAPMethodCall = [string, Record, string]; const SUBMISSION_USING = [ 'urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission', ] as const; // eslint-disable-next-line @typescript-eslint/no-explicit-any type JMAPResponseResult = Record; 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", // Needed so list rows can serve drag-out to the file system as .eml. "blobId", ] as const; // Stalwart's default property list for Calendar/get omits shareWith, isVisible, // includeInAvailability, and the default-alerts properties. Without an explicit // `properties` list the share indicator and share dialog can't see existing // shares after a fresh login (only the optimistic in-memory update from the // share action would carry it). Always request the full set we render. const CALENDAR_PROPERTIES = [ "id", "name", "description", "color", "sortOrder", "isSubscribed", "isVisible", "isDefault", "includeInAvailability", "defaultAlertsWithTime", "defaultAlertsWithoutTime", "timeZone", "shareWith", "myRights", ] as const; // Properties Stalwart's Calendar/set accepts in create/update. Anything else // (id, isDefault, myRights, client-side bookkeeping fields) makes the whole // update fail with invalidProperties ("Field could not be set"). const CALENDAR_SETTABLE_PROPERTIES = new Set([ "name", "description", "color", "timeZone", "sortOrder", "isSubscribed", "isVisible", "includeInAvailability", "defaultAlertsWithTime", "defaultAlertsWithoutTime", "shareWith", ]); // Stalwart's default property list for AddressBook/get omits shareWith, so // existing shares would be invisible after a fresh login. const ADDRESS_BOOK_PROPERTIES = [ "id", "name", "description", "sortOrder", "isDefault", "isSubscribed", "shareWith", "myRights", ] as const; /** * Detect whether a calendar object returned by the server is actually a * task (VTODO) rather than an event (VEVENT). CalDAV clients like * Thunderbird create VTODOs that Stalwart exposes through the * CalendarEvent endpoints without a reliable `@type` discriminator. */ function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown; percentComplete?: unknown }): boolean { const type = obj['@type']; if (typeof type === 'string' && type.toLowerCase() === 'task') return true; // CalDAV-created tasks may lack @type='Task' - detect by task-specific fields if (type !== 'Event' && ( ('progress' in obj && typeof obj.progress === 'string') || ('due' in obj && obj.due != null) || ('percentComplete' in obj) )) return true; return false; } const CALENDAR_EVENT_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated', 'sequence', 'start', 'duration', 'timeZone', 'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'privacy', 'color', 'keywords', 'categories', 'locale', 'replyTo', 'organizerCalendarAddress', 'participants', 'mayInviteSelf', 'mayInviteOthers', 'hideAttendees', 'recurrenceId', 'recurrenceIdTimeZone', 'recurrenceRule', 'recurrenceOverrides', 'excludedRecurrenceRule', 'useDefaultAlerts', 'alerts', 'locations', 'virtualLocations', 'links', 'relatedTo', 'isDraft', 'isOrigin', ] as const; // Task-specific properties for CalendarEvent/get when fetching Task objects const CALENDAR_TASK_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'descriptionContentType', 'created', 'updated', 'start', 'due', 'duration', 'timeZone', 'showWithoutTime', 'utcStart', 'utcEnd', 'progress', 'progressUpdated', 'priority', 'privacy', 'color', 'keywords', 'categories', 'recurrenceRule', 'recurrenceOverrides', 'excludedRecurrenceRule', 'useDefaultAlerts', 'alerts', 'relatedTo', 'percentComplete', // Task-only per RFC 8984 §5.2.4 - used in detection heuristic ] as const; /** * IANA time zone of the browser, sent as the `timeZone` argument on * CalendarEvent/query and CalendarEvent/get. Stalwart interprets the * LocalDateTime `after`/`before` filter values and computes utcStart/utcEnd * for floating events in this zone, defaulting to UTC when absent - which * shifts range boundaries and floating-event times for any user not in UTC. * Stalwart ignores unparseable values, so sending it is always safe. */ function getUserTimeZone(): string | undefined { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || undefined; } catch { return undefined; } } /** * Stalwart's calcard crate uses singular property names ("recurrenceRule") * instead of the RFC 8984 plural forms ("recurrenceRules"). * JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object, * not an array. This function converts our internal array form to a single * object, cleans null values, and renames the properties. */ function cleanRecurrenceRules(event: Record): void { const keyMap: Record = { recurrenceRules: 'recurrenceRule', excludedRecurrenceRules: 'excludedRecurrenceRule', }; for (const [pluralKey, singularKey] of Object.entries(keyMap)) { const rules = event[pluralKey]; if (rules === undefined) continue; delete event[pluralKey]; if (!Array.isArray(rules)) { // null means "remove recurrence" - pass through with the correct key event[singularKey] = rules; continue; } if (rules.length === 0) { event[singularKey] = null; continue; } // JSCalendar 2.0: recurrenceRule is a single object, use first rule const rule = rules[0] as Record; const cleaned: Record = {}; for (const [k, v] of Object.entries(rule)) { if (v !== null) cleaned[k] = v; } event[singularKey] = cleaned; } } function getCalendarEventDebugSnapshot(event: Partial | null | undefined): Record | null { if (!event) { return null; } return { id: event.id, originalId: event.originalId, uid: event.uid, '@type': event['@type'], title: event.title, start: event.start, duration: event.duration, timeZone: event.timeZone, showWithoutTime: event.showWithoutTime, utcStart: event.utcStart, utcEnd: event.utcEnd, status: event.status, freeBusyStatus: event.freeBusyStatus, calendarIds: event.calendarIds, originalCalendarIds: event.originalCalendarIds, accountId: event.accountId, accountName: event.accountName, isShared: event.isShared, recurrenceId: event.recurrenceId, recurrenceRules: event.recurrenceRules, sequence: event.sequence, created: event.created, updated: event.updated, }; } function namespaceMailboxIds(emails: Email[], accountId: string): void { for (const email of emails) { if (!email.mailboxIds) continue; const namespaced: Record = {}; 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; } function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean { return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/')); } function isSmimeEmail(email: Email): boolean { const types: string[] = []; const collect = (part: Email['bodyStructure']): void => { if (!part) return; if (part.type) types.push(part.type.toLowerCase()); part.subParts?.forEach(collect); }; collect(email.bodyStructure); email.attachments?.forEach(att => types.push((att.type || '').toLowerCase())); return types.some(type => type.includes('pkcs7') || type.includes('x-pkcs7') || type === 'application/pkcs7-mime' || type === 'application/pkcs7-signature' ); } /** * Fold a single iCalendar content line per RFC 5545 §3.1. * Lines longer than 75 octets MUST be split with CRLF + a single linear white space character. * We fold at 74 characters to leave room for the leading space on continuation lines. * @see https://www.rfc-editor.org/rfc/rfc5545#section-3.1 */ function foldIcsLine(line: string): string { const MAX = 74; if (line.length <= MAX) return line; const chunks: string[] = []; chunks.push(line.slice(0, MAX)); let pos = MAX; while (pos < line.length) { chunks.push(' ' + line.slice(pos, pos + MAX - 1)); pos += MAX - 1; } return chunks.join('\r\n'); } /** * Escape a TEXT property value per RFC 5545 §3.3.11. Backslash, semicolon, * comma and newlines must be escaped - otherwise a title like "1,2;3" or a * multi-line description corrupts the component. * @see https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11 */ function escapeIcsText(value: string): string { return value .replace(/\\/g, '\\\\') .replace(/;/g, '\\;') .replace(/,/g, '\\,') .replace(/\r\n|\r|\n/g, '\\n'); } /** * Encode a parameter value (e.g. CN=) per RFC 5545 §3.2: values containing * COLON, SEMICOLON or COMMA must be quoted; DQUOTE itself is not allowed in * parameter values, so replace it. */ function icsParamValue(value: string): string { const cleaned = value.replace(/[\r\n"]/g, "'"); return /[;:,]/.test(cleaned) ? `"${cleaned}"` : cleaned; } // JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that // snuck in (e.g. when echoing values that originated from RFC 5322 headers). function stripMessageIdBrackets(id: string): string { return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim(); } // Generate a Message-ID for outgoing mail (bare msg-id, no angle brackets, per // RFC 8621 §4.1.2.3). Without one the server synthesizes it from its OS // hostname, which leaks internal names (e.g. @ip-10-0-12-97.ec2.internal) into // headers — an anti-spam signal and an information disclosure. Use the sender's // domain instead, matching what receivers expect a Message-ID to look like. function generateMessageId(fromEmail: string): string { const at = fromEmail.lastIndexOf('@'); const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost'; return `${Date.now().toString(36)}.${crypto.randomUUID()}@${domain}`; } /** * Build a CalendarEvent/query filter restricting results to the given * calendars. Stalwart implements the singular `inCalendar` condition (one * calendar id per condition), not the draft's plural `inCalendars` array - * sending the plural form fails the whole query with `unsupportedFilter`. * Multiple calendars are expressed as an OR of singular conditions. */ function buildInCalendarFilter(calendarIds: string[]): Record { if (calendarIds.length === 1) { return { inCalendar: calendarIds[0] }; } return { operator: 'OR', conditions: calendarIds.map((id) => ({ inCalendar: id })), }; } // Some servers (notably Stalwart) return Identity.name in RFC 5322 mailbox // form: `Display Name `. Re-emitting that as the JMAP // from.name field produces a doubled From header (`"Name " `) // whose display-name is invalid per RFC 5322 §3.4 and gets rejected by the // submission validator - the email then sits forever in Drafts. function sanitizeIdentityDisplayName(name: string | undefined | null): string { if (!name) return ''; return name.replace(/\s*<[^>]*>\s*$/, '').trim(); } function normalizeEnvelopeRecipients(recipients?: Array): Array<{ email: string }> { // The JMAP envelope rcptTo/mailFrom take a bare addr-spec, not an RFC 5322 // mailbox. `to`/`cc`/`bcc` may arrive as "Name "; strip the display // name or the submission validator rejects the whole envelope (#…). return (recipients || []) .map((recipient) => typeof recipient === 'string' ? parseRecipientString(recipient).email : recipient.email) .map((email) => email.trim()) .filter(Boolean) .map((email) => ({ email })); } function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array): Record | undefined { if (!holdForSeconds) return undefined; const rcptTo = normalizeEnvelopeRecipients(recipients); return { mailFrom: { email: fromEmail, parameters: { HOLDFOR: String(holdForSeconds), }, }, rcptTo, }; } type SubmissionCapability = { maxDelayedSend?: number; submissionExtensions?: unknown; }; export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; private serverUrl: string; private username: string; private password: string; private basePassword: string = ''; private authHeader: string; private authMode: 'basic' | 'bearer' = 'basic'; private onTokenRefresh?: () => Promise; private onTotpRequired?: () => Promise; private apiUrl: string = ""; private accountId: string = ""; private downloadUrl: string = ""; private capabilities: Record = {}; private session: JMAPSession | null = null; private lastPingTime: number = 0; private pingInterval: NodeJS.Timeout | null = null; // Set by disconnect() so async callbacks that were already in flight // (keep-alive ping, SSE error handlers) cannot revive timers or // reconnect after an intentional sign-out (#588). private intentionallyDisconnected = false; // Consecutive keep-alive failures; failed pings skip upcoming ticks // (30s -> 1m -> 2m -> ~5m) instead of hammering a down server (#588). private pingFailureCount = 0; private pingSkipRemaining = 0; private accounts: Record = {}; 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; private rateLimitedUntil: number = 0; private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null; private rateLimitTimeout: NodeJS.Timeout | null = null; private lastRateLimitNoticeAt: number = 0; 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, ): 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}`; } async getSomeEmails(emailsId: string[], accountId?: string): Promise { try { const targetAccountId = accountId || this.accountId; if (!emailsId || emailsId.length === 0) { return []; } const emails: Email[] = []; for (const batchIds of batched(emailsId, this.getMaxObjectsInGet())) { const response = await this.request([ ["Email/get", { accountId: targetAccountId, ids: batchIds, properties: [...EMAIL_LIST_PROPERTIES], }, "0"], ]); const getResponse = response.methodResponses?.[0]?.[1]; if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) { emails.push(...((getResponse.list || []) as Email[])); } } emails.sort((a: Email, b: Email) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() ); if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); } return emails; } catch (error) { console.error('Failed to get specific emails:', error); return []; } } /** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */ upgradeToBearer(accessToken: string, onRefresh?: () => Promise): void { this.authMode = 'bearer'; this.authHeader = `Bearer ${accessToken}`; this.onTokenRefresh = onRefresh; } /** * Enable TOTP re-authentication for basic-auth sessions. * When a 401 is received, the callback is invoked to get a fresh TOTP code. * The base password (without TOTP) is stored so we can construct new credentials. */ enableTotpReauth(basePassword: string, callback: () => Promise): void { this.basePassword = basePassword; this.onTotpRequired = callback; } /** Update basic-auth credentials with a new password (e.g. password$newTotp). */ updateBasicAuth(newPassword: string): void { this.password = newPassword; this.authHeader = `Basic ${btoa(`${this.username}:${newPassword}`)}`; } getAuthHeader(): string { return this.authHeader; } getServerUrl(): string { return this.serverUrl; } private async authenticatedFetch(url: string, init?: Parameters[1]): Promise { // Short-circuit: if rate-limited, reject immediately without sending a request if (this.isRateLimited()) { const remaining = this.rateLimitedUntil - Date.now(); this.notifyRateLimitBlocked(remaining); throw new RateLimitError(remaining); } const headers = { ...init?.headers as Record, 'Authorization': this.authHeader }; let response: Response; try { response = await fetch(url, { ...init, headers }); } catch (error) { // A `fetch` REJECTION - and only that - is a transport failure. Recorded so // the offline replica's read fallback can tell "the network is down" from // "the folder is empty", which the error-swallowing in getEmails/getEmail/ // getMailboxes otherwise makes indistinguishable (see // lib/jmap/transport-health.ts). Deliberately NOT recorded for a 4xx/5xx or // a 429: in those cases the server answered, so it is reachable. noteTransportFailure(); // Network error: retry once after brief delay (transient proxy/connection issues) if (this.reconnecting) throw error; await new Promise(r => setTimeout(r, 1000)); try { response = await fetch(url, { ...init, headers }); } catch (retryError) { noteTransportFailure(); throw retryError; } } noteTransportSuccess(); // Handle 429 rate limiting - stop immediately, do not retry if (response.status === 429) { const retryAfterMs = JMAPClient.parseRetryAfter(response); this.setRateLimited(retryAfterMs); throw new RateLimitError(retryAfterMs); } 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, '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, 'Authorization': this.authHeader }; response = await fetch(url, { ...init, headers: retryHeaders }); } catch { // Session refresh failed - if TOTP was used, try re-auth with fresh TOTP if (this.onTotpRequired && this.basePassword) { try { const newTotp = await this.onTotpRequired(); if (newTotp) { this.updateBasicAuth(`${this.basePassword}$${newTotp}`); await this.refreshSession(); this.connectionChangeCallback?.(true); const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await fetch(url, { ...init, headers: retryHeaders }); } } catch { // TOTP re-auth also failed - return original 401 } } } finally { this.reconnecting = false; } } } return response; } /** * Fetch the JMAP session, transparently handling servers that redirect * /.well-known/jmap to a canonical session URL (e.g. Stalwart → /jmap/session). * * Safari strips the Authorization header on cross-origin redirects even when * the redirect destination is same-origin as the original request, and some * reverse-auth proxies (e.g. Pangolin) admit the redirected request via a * cookie without the Authorization header. Stalwart responds to an * unauthenticated /jmap/session with 200 + empty accounts rather than 401, * so the drop is silent and downstream parsing fails with "No mail account * found in session". * * Detect that case (response.redirected, empty accounts, empty username) * and retry directly against the final URL so we can re-send Authorization. */ private async fetchSessionResponse(): Promise { const discoveryUrl = `${this.serverUrl}/.well-known/jmap`; const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' }); if (!response.ok || !response.redirected) return response; const peek = await response.clone().json().catch(() => null); const hasAccounts = peek && Object.keys(peek.accounts || {}).length > 0; const hasUsername = typeof peek?.username === 'string' && peek.username.length > 0; if (hasAccounts || hasUsername) return response; return fetch(response.url, { method: 'GET', headers: { 'Authorization': this.authHeader }, }); } private async refreshSession(): Promise { const response = await this.fetchSessionResponse(); 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 { this.intentionallyDisconnected = false; const sessionUrl = `${this.serverUrl}/.well-known/jmap`; try { const sessionResponse = await this.fetchSessionResponse(); if (!sessionResponse.ok) { if (sessionResponse.status === 401) { throw new Error(this.authMode === 'bearer' ? 'Authentication failed - token may be expired' : 'Invalid username or password'); } if (sessionResponse.status === 402) { try { const body = await sessionResponse.json(); // Older Stalwart titled this "TOTP code required"; 0.16+ uses the // generic "MFA code required" - accept either to trigger the prompt. const title = body?.title?.toLowerCase() ?? ''; if (title.includes('totp') || title.includes('mfa')) { throw new Error('TOTP_REQUIRED'); } } catch (e) { if (e instanceof Error && e.message === 'TOTP_REQUIRED') throw e; } } 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') || error.message === 'Load failed' || error.message === 'cancelled' )) { 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 () => { if (this.intentionallyDisconnected) return; // Skip ping while rate-limited to avoid compounding auth failures if (this.isRateLimited()) return; // Back off while the server is down: each consecutive failure skips // more ticks (30s -> 1m -> 2m -> ~5m) instead of retrying flat-out. if (this.pingSkipRemaining > 0) { this.pingSkipRemaining--; return; } try { await this.ping(); this.pingFailureCount = 0; this.connectionChangeCallback?.(true); } catch (error) { if (error instanceof RateLimitError) { return; } // A sign-out while the ping was in flight - stay down. if (this.intentionallyDisconnected) return; this.pingFailureCount++; this.pingSkipRemaining = Math.min(2 ** this.pingFailureCount, 10) - 1; console.error('Keep-alive ping failed:', error); this.connectionChangeCallback?.(false); try { await this.reconnect(); this.pingFailureCount = 0; this.pingSkipRemaining = 0; 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 { 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 { if (this.intentionallyDisconnected) return; await this.connect(); } disconnect(): void { this.intentionallyDisconnected = true; this.stopKeepAlive(); this.closePushNotifications(); if (this.rateLimitTimeout) { clearTimeout(this.rateLimitTimeout); this.rateLimitTimeout = null; } 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); } const wsCapability = session.capabilities?.["urn:ietf:params:jmap:websocket"] as | { url?: string } | undefined; if (wsCapability?.url) { wsCapability.url = this.rewriteWebSocketUrl(wsCapability.url); } } /** * Same reasoning as rewriteSessionUrl (a reverse proxy may advertise its * own internal hostname), but scheme-aware: unlike apiUrl/eventSourceUrl, * this URL is never touched by fetch() - it goes straight into `new * WebSocket(...)`, and a ws/wss URL can never share an origin string with * an http/https serverUrl even when the host is identical, so reusing * rewriteSessionUrl's plain origin-equality check would rewrite EVERY * websocket URL onto an http(s) scheme and break the constructor outright. */ private rewriteWebSocketUrl(url: string): string { try { const parsed = new URL(url); const server = new URL(this.serverUrl); const expectedScheme = server.protocol === "https:" ? "wss:" : "ws:"; if (parsed.host === server.host && parsed.protocol === expectedScheme) { return url; } const pathAndRest = url.slice(url.indexOf("/", url.indexOf("//") + 2)); return `${expectedScheme}//${server.host}${pathAndRest}`; } catch { return url; } } private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise { if (!this.apiUrl) { throw new Error('Not connected. Call connect() first.'); } const requestBody = { using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_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> { if (!this.supportsQuota()) return null; try { const response = await this.request([ ["Quota/get", { accountId: this.accountId, }, "0"] ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:quota"]); if (response.methodResponses?.[0]?.[0] === "Quota/get") { const quotas = (response.methodResponses[0][1].list || []) as JMAPQuota[]; const coversMail = (q: JMAPQuota) => !q.types?.length || q.types.some((t) => t === "Email" || t === "Mail"); // storage quotas use resourceType "octets" (e.g. Stalwart, with // scope "account"); fall back to the pre-RFC "mail" shape for older servers. const mailQuota = quotas.find((q) => q.resourceType === "octets" && coversMail(q)) || 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(accountId?: string): Promise { const acctId = accountId || this.accountId; try { const response = await this.request([ ["Mailbox/get", { accountId: acctId }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Mailbox/get") { const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${acctId}`); // Warn if response might be truncated const maxObjects = this.getMaxObjectsInGet(); if (rawMailboxes.length >= maxObjects) { debug.warn('jmap', `[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` + `Some mailboxes may be missing - nested folders could appear orphaned at root level.` ); } // Log parentId references to detect potential orphans const returnedIds = new Set(rawMailboxes.map(mb => mb.id)); const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId)); if (missingParents.length > 0) { debug.warn('jmap', `[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`, missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId })) ); } 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: acctId, accountName: this.accounts[acctId]?.name || this.username, isShared: acctId !== this.accountId, }) 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 { try { const allMailboxes: Mailbox[] = []; const accountIds = Object.keys(this.accounts); if (accountIds.length === 0) { return this.getMailboxes(); } let fetchFailed = false; 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[]; debug.log('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`); // Warn if response might be truncated const maxObjects = this.getMaxObjectsInGet(); if (rawMailboxes.length >= maxObjects) { debug.warn('jmap', `[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` + `Some mailboxes may be missing.` ); } 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) { fetchFailed = true; console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); } } // If every account fetch failed (e.g. a transient maxConcurrentRequests // limit during a burst of deletes) we have an empty list that is NOT a // real "this account has no mailboxes" result. Throwing lets the caller's // catch preserve the existing folder list instead of clobbering it with // [] — which would leave the sidebar stuck on "Loading mailboxes...". if (allMailboxes.length === 0 && fetchFailed) { throw new Error('Failed to fetch mailboxes for all accounts'); } 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, pinnedFirst?: boolean, extraFilter?: Record): Promise<{ emails: Email[], hasMore: boolean, total: number }> { const tcBefore = transportFailureCount(); try { const targetAccountId = accountId || this.accountId; const simple: { inMailbox?: string; hasKeyword?: string } = {}; if (mailboxId) { simple.inMailbox = mailboxId; } if (hasKeyword) { simple.hasKeyword = hasKeyword; } // `extraFilter` is an arbitrary FilterCondition/FilterOperator ANDed // into the view - the message-list category tabs' search contract. const filter: Record = extraFilter ? { operator: "AND", conditions: [ ...(Object.keys(simple).length > 0 ? [simple] : []), extraFilter, ], } : simple; // Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2); // every page of a view must use the same sort or pagination tears. const sort = pinnedFirst ? [ { property: "hasKeyword", keyword: "$pinned", isAscending: false }, { property: "receivedAt", isAscending: false }, ] : [{ property: "receivedAt", isAscending: false }]; const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter, sort, limit, position, calculateTotal: true, }, "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 || []) as Email[]; // Sort client-side as safety net - some servers may not honour // the query sort for large mailboxes without additional filters. // Must mirror the query sort, or it would undo the pinned-first order. const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0); emails.sort((a: Email, b: Email) => pinRank(b) - pinRank(a) || new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() ); 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) { if (wasTransportFailure(tcBefore)) { throw new TransportError('Failed to get emails: network transport failure'); } console.error('Failed to get emails:', error); return { emails: [], hasMore: false, total: 0 }; } } async getEmailsInMailbox(mailboxId: string): Promise { const allEmails: Email[] = []; let position = 0; const batchSize = 100; 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> { if (tagIds.length === 0) return {}; const result: Record = {}; const CALLS_PER_TAG = 2; const perRequest = itemsPerRequest(this.getMaxCallsInRequest(), CALLS_PER_TAG); for (const batch of batched(tagIds, perRequest)) { try { const methodCalls: JMAPMethodCall[] = []; for (let i = 0; i < batch.length; i++) { const keyword = `$label:${batch[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); for (let i = 0; i < batch.length; i++) { const totalResp = response.methodResponses?.[i * 2]?.[1]; const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1]; result[batch[i]] = { total: totalResp?.total ?? 0, unread: unreadResp?.total ?? 0, }; } } catch (error) { console.error('Failed to get tag counts:', error); } } return result; } /** * Per-tab unread counts for message-list category tabs. One Email/query * (limit 0, calculateTotal) per tab, batched into as few requests as the * server's method-call ceiling allows. Each entry's `filter` is the tab's * resolved FilterCondition/FilterOperator (null = no extra condition, i.e. * all unread in the mailbox). */ async getCategoryUnreadCounts( mailboxId: string, tabs: Array<{ id: string; filter: Record | null }>, accountId?: string, ): Promise> { if (tabs.length === 0) return {}; const targetAccountId = accountId || this.accountId; const result: Record = {}; for (const batch of batched(tabs, this.getMaxCallsInRequest())) { try { const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => { const conditions: Record[] = [ { inMailbox: mailboxId }, { notKeyword: "$seen" }, ]; if (tab.filter) conditions.push(tab.filter); return ["Email/query", { accountId: targetAccountId, filter: { operator: "AND", conditions }, limit: 0, calculateTotal: true, }, `tab_${i}`]; }); const response = await this.request(methodCalls); for (let i = 0; i < batch.length; i++) { result[batch[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0; } } catch (error) { console.error('Failed to get category tab counts:', error); } } return result; } async getEmail(emailId: string, accountId?: string): Promise { 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 { const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers'); let headersRecord: Record; 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; } const authResultsHeader = headersRecord['Authentication-Results']; if (authResultsHeader) { // Multiple Authentication-Results headers (or multiple SPF identities in // one header) must all be considered so the most severe result wins. const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader; email.authenticationResults = parseAuthenticationResults(value); } for (const headerName of ['X-Spam-Score', '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).trim()); 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 { 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, accountId?: string): Promise { if (emailIds.length === 0) return; for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read }])); await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], ]); } } async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise { await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: { [emailId]: { "keywords/$flagged": starred, }, }, }, "0"], ]); } async updateEmailKeywords(emailId: string, keywords: Record, accountId?: string): Promise { await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: { [emailId]: { keywords, }, }, }, "0"], ]); } async setKeyword(emailId: string, keyword: string, accountId?: string): Promise { await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: { [emailId]: { [`keywords/${keyword}`]: true, }, }, }, "0"], ]); } async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise { await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: { [emailId]: { [`keywords/${keyword}`]: null, }, }, }, "0"], ]); } /** * Apply the same keyword PatchObject fragment to many messages in one * Email/set. `patch` keys are `keywords/` pointers with true (add) * or null (remove) values - the category-tab move primitive. */ async batchUpdateKeywords(emailIds: string[], patch: Record, accountId?: string): Promise { if (emailIds.length === 0 || Object.keys(patch).length === 0) return; for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { const update = Object.fromEntries(batch.map(id => [id, { ...patch }])); await this.request([ ["Email/set", { accountId: accountId || this.accountId, update }, "0"], ]); } } async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { // Query all email IDs that have the old keyword const allIds: string[] = []; let position = 0; const batchSize = 100; while (true) { const response = await this.request([ ["Email/query", { accountId: this.accountId, filter: { hasKeyword: oldKeyword }, limit: batchSize, position, }, "0"], ]); const queryResult = response.methodResponses?.[0]?.[1]; const ids: string[] = queryResult?.ids || []; allIds.push(...ids); if (ids.length < batchSize) break; position += ids.length; } if (allIds.length === 0) return 0; // Batch update: remove old keyword, add new keyword using per-property patches for (const batch of batched(allIds, this.getMaxObjectsInSet())) { const update: Record> = {}; for (const id of batch) { update[id] = { [`keywords/${oldKeyword}`]: null, [`keywords/${newKeyword}`]: true, }; } await this.request([ ["Email/set", { accountId: this.accountId, update, }, "0"], ]); } return allIds.length; } async deleteEmail(emailId: string, accountId?: string): Promise { await this.request([ ["Email/set", { accountId: accountId || this.accountId, destroy: [emailId], }, "0"], ]); } async moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise { const targetAccountId = accountId || this.accountId; const patch: Record = { mailboxIds: { [trashMailboxId]: true } }; if (markAsRead) patch["keywords/$seen"] = true; await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: patch }, }, "0"], ]); } async batchDeleteEmails(emailIds: string[], accountId?: string): Promise { if (emailIds.length === 0) return; for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { await this.request([ ["Email/set", { accountId: accountId || this.accountId, destroy: batch, }, "0"], ]); } } async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise { if (emailIds.length === 0) return; const buildPatch = () => { const patch: Record = { mailboxIds: { [toMailboxId]: true } }; if (markAsRead) patch["keywords/$seen"] = true; return patch; }; for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { const updates = Object.fromEntries(batch.map(id => [id, buildPatch()])); await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], ]); } } async batchArchiveEmails( emails: Array<{ id: string; receivedAt: string }>, archiveMailboxId: string, mode: 'single' | 'year' | 'month', existingMailboxes: Mailbox[], accountId?: string, ): Promise { if (emails.length === 0) return; const targetAccountId = accountId || this.accountId; if (mode === 'single') { await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId); return; } type Dest = { year: string; month?: string }; const destFor = new Map(); for (const e of emails) { const d = new Date(e.receivedAt); const year = d.getFullYear().toString(); const month = (d.getMonth() + 1).toString().padStart(2, '0'); destFor.set(e.id, mode === 'year' ? { year } : { year, month }); } // Resolve each destination folder to either an existing id or a creation-id reference ("#"). const yearIdFor = new Map(); const monthIdFor = new Map(); const createEntries: Record> = {}; const findExisting = (name: string, parentId: string) => existingMailboxes.find(m => m.accountId === targetAccountId && m.name === name && (m.parentId === parentId || m.parentId === (parentId.startsWith('#') ? undefined : parentId)), ); for (const dest of destFor.values()) { if (!yearIdFor.has(dest.year)) { const existing = findExisting(dest.year, archiveMailboxId); if (existing) { yearIdFor.set(dest.year, existing.originalId || existing.id); } else { const cid = `year-${dest.year}`; createEntries[cid] = { name: dest.year, parentId: archiveMailboxId }; yearIdFor.set(dest.year, `#${cid}`); } } if (mode === 'month' && dest.month) { const monthKey = `${dest.year}/${dest.month}`; if (!monthIdFor.has(monthKey)) { const yearRef = yearIdFor.get(dest.year)!; // Only look up existing month folders under real (non-creation-ref) year ids. const existingMonth = yearRef.startsWith('#') ? undefined : findExisting(dest.month, yearRef); if (existingMonth) { monthIdFor.set(monthKey, existingMonth.originalId || existingMonth.id); } else { const cid = `month-${dest.year}-${dest.month}`; createEntries[cid] = { name: dest.month, parentId: yearRef }; monthIdFor.set(monthKey, `#${cid}`); } } } } const updates: Record }> = {}; for (const [emailId, dest] of destFor.entries()) { const destId = mode === 'month' && dest.month ? monthIdFor.get(`${dest.year}/${dest.month}`)! : yearIdFor.get(dest.year)!; updates[emailId] = { mailboxIds: { [destId]: true } }; } // Creation ids are scoped to the request that introduced them (RFC 8620 // §3.3), so "#" only resolves in the request carrying the Mailbox/set: // the folders are created alongside the first batch of messages, and the // ids they were assigned are substituted into every later batch. const updateBatches = batched(Object.entries(updates), this.getMaxObjectsInSet()); const hasCreates = Object.keys(createEntries).length > 0; let createdIdFor: Record = {}; for (let i = 0; i < updateBatches.length; i++) { const batch: Array<[string, { mailboxIds: Record }]> = i === 0 ? updateBatches[i] : updateBatches[i].map(([emailId, patch]) => { const [destId] = Object.keys(patch.mailboxIds); const resolved = createdIdFor[destId]; return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record } : patch]; }); const methodCalls: JMAPMethodCall[] = []; const withCreates = hasCreates && i === 0; if (withCreates) { methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']); } methodCalls.push(['Email/set', { accountId: targetAccountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]); const response = await this.request(methodCalls); if (withCreates) { const mailboxResult = response.methodResponses?.[0]?.[1]; const notCreated = mailboxResult?.notCreated as Record | undefined; const failures = notCreated ? Object.entries(notCreated) : []; if (failures.length > 0) { const [cid, err] = failures[0]; const parts = [err.type || 'unknown']; if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`); if (err.description) parts.push(err.description); throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`); } const created = (mailboxResult?.created || {}) as Record; createdIdFor = Object.fromEntries( Object.entries(created) .filter(([, mailbox]) => !!mailbox?.id) .map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]), ); } const emailIdx = withCreates ? 1 : 0; const emailResult = response.methodResponses?.[emailIdx]?.[1]; const notUpdated = emailResult?.notUpdated as Record | undefined; const emailFailures = notUpdated ? Object.entries(notUpdated) : []; if (emailFailures.length > 0) { const [id, err] = emailFailures[0]; throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`); } } } async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise { 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, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const batchSize = Math.min(500, this.getMaxObjectsInSet()); let totalDestroyed = 0; // Destroy in batches until the mailbox is empty. Never gate the loop on // Email/query's `total`: it is only guaranteed when `calculateTotal` is // requested, and Stalwart omits it otherwise, which used to stop the loop // after the first batch and leave folders with >500 emails mostly intact. while (true) { const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter: { inMailbox: mailboxId }, limit: batchSize, }, "0"], ["Email/set", { accountId: targetAccountId, "#destroy": { resultOf: "0", name: "Email/query", path: "/ids" }, }, "1"], ]); const queryResult = response.methodResponses?.[0]?.[1]; const setResult = response.methodResponses?.[1]?.[1]; const found: string[] = queryResult?.ids || []; const destroyed = setResult?.destroyed?.length || 0; totalDestroyed += destroyed; // Nothing left, or the server refused everything in this batch (missing // permission, immutable mail) — stop instead of looping forever on the // same ids. if (found.length === 0 || destroyed === 0) break; // A short page means we just handled the tail of the mailbox. if (found.length < batchSize) break; } return totalDestroyed; } async markMailboxAsRead(mailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const pageSize = Math.min(500, this.getMaxObjectsInSet()); let totalMarked = 0; let hasMore = true; while (hasMore) { const queryResponse = await this.request([ ["Email/query", { accountId: targetAccountId, filter: { operator: "AND", conditions: [ { inMailbox: mailboxId }, { notKeyword: "$seen" }, ], }, limit: pageSize, }, "0"], ]); const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || []; if (ids.length === 0) break; const updates = Object.fromEntries( ids.map((id) => [id, { "keywords/$seen": true }]) ); await this.request([ ["Email/set", { accountId: targetAccountId, update: updates }, "0"], ]); totalMarked += ids.length; hasMore = ids.length === pageSize; } return totalMarked; } async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const excludeSet = new Set(excludeMailboxIds); const pageSize = Math.min(500, this.getMaxObjectsInGet(), this.getMaxObjectsInSet()); let totalMarked = 0; let hasMore = true; let position = 0; while (hasMore) { const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter: { notKeyword: "$seen" }, limit: pageSize, position, }, "0"], ["Email/get", { accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: ["id", "mailboxIds"], }, "1"], ]); const queryResult = response.methodResponses?.[0]?.[1]; const getResult = response.methodResponses?.[1]?.[1]; const ids: string[] = queryResult?.ids || []; const emails: Array<{ id: string; mailboxIds?: Record }> = getResult?.list || []; if (ids.length === 0) break; const targetIds = excludeSet.size === 0 ? ids : emails .filter(e => { const mbIds = e.mailboxIds ? Object.keys(e.mailboxIds) : []; return mbIds.some(id => !excludeSet.has(id)); }) .map(e => e.id); if (targetIds.length > 0) { const updates = Object.fromEntries( targetIds.map((id) => [id, { "keywords/$seen": true }]) ); await this.request([ ["Email/set", { accountId: targetAccountId, update: updates }, "0"], ]); totalMarked += targetIds.length; } hasMore = ids.length === pageSize; position += ids.length; } return totalMarked; } async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise { const targetAccountId = accountId || this.accountId; const mailboxes = await this.getMailboxes(accountId); 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; const patch: Record = { mailboxIds: { [mailboxId]: true } }; if (markAsRead) patch["keywords/$seen"] = true; await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: patch }, }, "0"], ]); } async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: { mailboxIds: { [originalMailboxId]: true }, }, }, }, "0"], ]); } async createMailbox(name: string, parentId?: string, accountId?: string): Promise { const createId = `new-${Date.now()}`; const createData: Record = { name }; if (parentId) { createData.parentId = parentId; } const response = await this.request([ ["Mailbox/set", { accountId: accountId || this.accountId, create: { [createId]: createData }, }, "0"], ]); const result = response.methodResponses?.[0]?.[1]; if (result?.notCreated?.[createId]) { const err = result.notCreated[createId]; const details = [err.type || 'unknown error']; if (Array.isArray(err.properties) && err.properties.length > 0) { details.push(`properties=[${err.properties.join(', ')}]`); } if (err.description) details.push(err.description); throw new Error(`Failed to create mailbox: ${details.join(' – ')}`); } 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 { 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 { const response = await this.request([ ["Mailbox/set", { accountId: this.accountId, destroy: [mailboxId], }, "0"], ]); const result = response.methodResponses?.[0]?.[1]; if (result?.notDestroyed?.[mailboxId]) { const err = result.notDestroyed[mailboxId]; const error = new Error(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`); (error as Error & { jmapType?: string }).jmapType = err.type; throw error; } } async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { const tcBefore = transportFailureCount(); 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 = { text: wildcardQuery }; let filter: Record; 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, calculateTotal: true, }, "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 || []) as Email[]; emails.sort((a: Email, b: Email) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() ); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); // Mirror getEmails: emails fetched from a delegated/shared account carry // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line // up with the namespaced ids the store holds for shared mailboxes. (#281 V3) if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); } return { emails, hasMore, total }; } catch (error) { if (wasTransportFailure(tcBefore)) { throw new TransportError('Search failed: network transport failure'); } console.error('Search failed:', error); return { emails: [], hasMore: false, total: 0 }; } } async advancedSearchEmails( filter: Record, 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, calculateTotal: true, }, "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 || []) as Email[]; emails.sort((a: Email, b: Email) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() ); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); // Namespace shared/delegated-account mailbox ids (see searchEmails). The // cross-account views (All mail / Unread / Starred) browse via this method, // so without it shared emails would carry bare owner ids there. (#281 V3) if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); } return { emails, hasMore, total }; } catch (error) { console.error('Advanced search failed:', error); throw error; } } async searchSentRecipients(query: string, sentMailboxId: string, accountId?: string, limit: number = 60): Promise> { const q = query.trim(); if (!q || !sentMailboxId) return []; try { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter: { operator: "AND", conditions: [ { inMailbox: sentMailboxId }, { operator: "OR", conditions: [{ to: q }, { cc: q }] }, ], }, sort: [{ property: "receivedAt", isAscending: false }], limit, }, "0"], // Fetch ONLY the recipient fields - no subject/preview/body/attachments. ["Email/get", { accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: ["to", "cc"], }, "1"], ]); const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[]; const lower = q.toLowerCase(); const byEmail = new Map(); for (const email of emails) { for (const r of [...(email.to || []), ...(email.cc || [])]) { if (!r.email) continue; const key = r.email.toLowerCase().trim(); if (!key || byEmail.has(key)) continue; // The query matched *some* recipient of the message; keep only the // addresses that actually match, not every co-recipient. if (key.includes(lower) || (r.name && r.name.toLowerCase().includes(lower))) { byEmail.set(key, { name: (r.name || "").trim(), email: r.email }); } } } return Array.from(byEmail.values()); } catch (error) { console.error('Recipient search failed:', error); return []; } } async getThread(threadId: string, accountId?: string): Promise { 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 getThreads(threadIds: string[], accountId?: string): Promise { if (threadIds.length === 0) return []; try { const targetAccountId = accountId || this.accountId; const threads: Thread[] = []; for (const batchIds of batched(threadIds, this.getMaxObjectsInGet())) { const response = await this.request([ ["Thread/get", { accountId: targetAccountId, ids: batchIds }, "0"], ]); if (response.methodResponses?.[0]?.[0] === "Thread/get") { threads.push(...((response.methodResponses[0][1].list || []) as Thread[])); } } return threads; } catch (error) { console.error('Failed to get threads:', error); return []; } } async getThreadEmails(threadId: string, accountId?: string): Promise { try { const targetAccountId = accountId || this.accountId; const thread = await this.getThread(threadId, accountId); if (!thread?.emailIds?.length) { return []; } const emails: Email[] = []; for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) { const response = await this.request([ ["Email/get", { accountId: targetAccountId, ids: batchIds, 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") { emails.push(...(response.methodResponses[0][1].list || [])); } } if (emails.length > 0) { 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 { try { const response = await this.request([ ["Identity/get", { accountId: this.accountId, }, "0"] ]); if (response.methodResponses?.[0]?.[0] === "Identity/get") { const list = (response.methodResponses[0][1].list || []) as Identity[]; return list.map((id) => ({ ...id, name: sanitizeIdentityDisplayName(id.name) })); } return []; } catch (error) { console.error('Failed to get identities:', error); return []; } } async createIdentity( name: string, email: string, replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null, textSignature?: string | null, htmlSignature?: string | null ): Promise { 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 | null; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; textSignature?: string | null; htmlSignature?: string | null; } ): Promise { 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 { 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(accountId?: string): Promise { const response = await this.request([ ["VacationResponse/get", { accountId: 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, accountId?: string): Promise { const response = await this.request([ ["VacationResponse/set", { accountId: 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; disposition?: 'attachment' | 'inline'; cid?: string }>, fromName?: string, htmlBody?: string ): Promise { 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; mailboxIds: Record; bodyValues: Record; textBody: { partId: string; type?: string }[]; htmlBody?: { partId: string; type: string }[]; attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[]; } const sanitizedFromName = sanitizeIdentityDisplayName(fromName); const emailData: EmailDraft = { from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }], to: to.map(email => ({ email })), cc: cc?.length ? cc.map(email => ({ email })) : undefined, bcc: bcc?.length ? bcc.map(email => ({ email })) : undefined, subject, keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyValues: htmlBody ? { "text": { value: body }, "html": { value: htmlBody } } : { "1": { value: body } }, textBody: htmlBody ? [{ partId: "text", type: "text/plain" }] : [{ partId: "1" }], ...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}), }; if (attachments?.length) { emailData.attachments = attachments.map(att => ({ blobId: att.blobId, type: att.type, name: att.name, disposition: att.disposition ?? "attachment", ...(att.cid ? { cid: att.cid } : {}), })); } // Use a single Email/set call with both destroy and create for atomicity const setArgs: Record = { accountId: this.accountId, create: { [emailId]: emailData }, }; if (draftId) { setArgs.destroy = [draftId]; } const methodCalls: JMAPMethodCall[] = [ ["Email/set", setArgs, "0"], ]; const response = await this.request(methodCalls); if (response.methodResponses?.[0]?.[0] === "Email/set") { const result = response.methodResponses[0][1]; if (result.notCreated) { const errors = result.notCreated; 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 (draftId && result.notDestroyed) { console.warn('Failed to destroy old draft:', result.notDestroyed); } 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; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean } ): Promise { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; const emailId = `send-${Date.now()}`; const targetAccountId = (fromEmail && Object.keys(this.accounts).find(id => this.accounts[id]?.name?.toLowerCase() === fromEmail.toLowerCase() )) || this.accountId; const mboxResp = await this.request([ ["Mailbox/get", { accountId: targetAccountId }, "0"] ]); const mailboxes = (mboxResp.methodResponses?.[0]?.[1]?.list || []) as Mailbox[]; const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { throw new Error('No sent mailbox found'); } const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); if (!draftsMailbox) { throw new Error('No drafts mailbox found'); } let finalIdentityId = identityId; let identityReplyTo: EmailAddress[] | undefined; { const identityResponse = await this.request([ ["Identity/get", { accountId: targetAccountId }, "0"] ]); if (!finalIdentityId) { finalIdentityId = targetAccountId; } if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") { const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[]; if (identities.length > 0) { let matchingIdentity = identityId ? identities.find((id) => id.id === identityId) : undefined; if (!matchingIdentity) { const target = fromEmail || this.username; matchingIdentity = identities.find((id) => id.email === target) || (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined); } finalIdentityId = matchingIdentity?.id || identities[0].id; identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo; } } } // Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids // (no angle brackets). Stalwart may return them either way, so normalize. const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean); const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean); const sanitizedFromName = sanitizeIdentityDisplayName(fromName); // Always create a new email with the final body content const emailCreate: Record = { from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }], replyTo: identityReplyTo?.length ? identityReplyTo : undefined, to: to.map(parseRecipientString), // RFC 5322 §3.6.3: To/Cc carry an address-list (non-empty). Sending // cc:[] makes the server emit a literal `Cc:` header with no addresses, // which is malformed and a spam signal. Omit the field when empty. cc: cc?.length ? cc.map(parseRecipientString) : undefined, bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined, subject, messageId: [generateMessageId(fromEmail || this.username)], inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined, references: normalizedReferences?.length ? normalizedReferences : undefined, keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, }; if (options?.requestReadReceipt) { // RFC 8098: ask the recipient's client to return a Message Disposition // Notification to our address. JMAP lets us set the raw header on create // via the "header::asText" property form. emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username; } 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: att.disposition ?? "attachment", ...(att.cid ? { cid: att.cid } : {}), })); } const methodCalls: JMAPMethodCall[] = []; // Use onSuccessUpdateEmail to move from Drafts to Sent after submission. // This ensures SMTP send happens before the email lands in Sent, avoiding // issues with servers that encrypt on append (e.g. Stalwart). See #188. const onSuccessUpdateEmail = { "#1": { ...mailboxIdsReplacement(sentMailbox.id), "keywords/$draft": null, }, }; // When an explicit envelope MAIL FROM is provided (header From ≠ envelope, // e.g. sending from a domain-catch-all alias without a dedicated Identity), // set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope` // is omitted the server derives mailFrom from the Identity. const buildSubmissionCreate = (submissionId: string): Record => { const create: Record = { emailId: `#${emailId}`, identityId: finalIdentityId }; if (holdForSeconds || envelopeMailFrom) { const envelopeRecipients = normalizeEnvelopeRecipients([...to, ...(cc || []), ...(bcc || [])]); create.envelope = { mailFrom: { email: parseRecipientString(envelopeMailFrom || fromEmail || this.username).email, ...(holdForSeconds ? { parameters: { HOLDFOR: String(holdForSeconds) } } : {}), }, rcptTo: envelopeRecipients, }; } return { [submissionId]: create }; }; 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: targetAccountId, create: { [emailId]: emailCreate }, }, "1"]); methodCalls.push(["EmailSubmission/set", { accountId: this.getSubmissionAccountId(targetAccountId), create: buildSubmissionCreate("1"), onSuccessUpdateEmail, }, "2"]); } else { methodCalls.push(["Email/set", { accountId: targetAccountId, create: { [emailId]: emailCreate }, }, "0"]); methodCalls.push(["EmailSubmission/set", { accountId: this.getSubmissionAccountId(targetAccountId), create: buildSubmissionCreate("1"), onSuccessUpdateEmail, }, "1"]); } const response = await this.request(methodCalls); let createdEmailId: string | undefined; let emailSubmissionId: string | undefined; let serverSendAt: string | undefined; let filingError: string | undefined; if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { if (methodName.endsWith('/error')) { console.error('[sendEmail] JMAP method error:', methodName, result); throw new Error(result.description || `Failed to send email: ${result.type}`); } if (result.notCreated) { // Include method name + full error object so it's clear whether the // failure came from Email/set (draft create) or EmailSubmission/set // (actual send) and which JMAP error type/properties were returned. // Without this the user sees a generic "Failed to send" toast and // the draft sits in Drafts with no indication of why (#303). const errors = result.notCreated as Record; const firstError = Object.values(errors)[0]; console.error( `[sendEmail] ${methodName} notCreated:`, JSON.stringify(errors, null, 2), ); const propsHint = firstError?.properties?.length ? ` (properties: ${firstError.properties.join(', ')})` : ''; const typeHint = firstError?.type ? ` [${firstError.type}]` : ''; throw new Error( `${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`, ); } // Post-submission filing problems (the implicit Email/set from // onSuccessUpdateEmail, or destroying the old draft) must not fail // the send - the message already left - but they must not stay // silent either: a silently rejected filing/cleanup is exactly how // "sent mail still sits in Drafts" reports look (#592, #588's // sibling note in 4dc76bbb). Log the details and surface a warning // to the caller. if (result.notUpdated && Object.keys(result.notUpdated).length) { console.error(`[sendEmail] ${methodName} notUpdated:`, JSON.stringify(result.notUpdated, null, 2)); const first = Object.values(result.notUpdated as Record)[0]; filingError = filingError ?? (first?.description || first?.type || 'post-send filing failed'); } if (result.notDestroyed && Object.keys(result.notDestroyed).length) { console.error(`[sendEmail] ${methodName} notDestroyed (old draft):`, JSON.stringify(result.notDestroyed, null, 2)); const first = Object.values(result.notDestroyed as Record)[0]; filingError = filingError ?? (first?.description || first?.type || 'old draft cleanup failed'); } if (methodName === 'Email/set' && result.created?.[emailId]?.id) { createdEmailId = result.created[emailId].id; } if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) { emailSubmissionId = result.created['1'].id; serverSendAt = result.created['1'].sendAt; } } } if (delayedUntil && emailSubmissionId && !serverSendAt) { serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); } return delayedUntil ? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt, filingError } : { scheduled: false, emailId: createdEmailId, emailSubmissionId, filingError }; } /** * Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP. * * Fallback only: when `sendSchedulingMessages` is passed to the * CalendarEvent/set RSVP patch, the server sends the iTIP REPLY itself - * calling this in addition produces duplicate reply emails. */ 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 { if (!opts.uid) { debug.warn('calendar', '[iMIP] sendImipReply aborted: missing UID'); return; } const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { throw new Error('No sent mailbox found'); } const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); if (!draftsMailbox) { throw new Error('No drafts 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:${escapeIcsText(opts.summary)}`); } if (opts.sequence != null) { lines.push(`SEQUENCE:${opts.sequence}`); } const orgCn = opts.organizerName ? `;CN=${icsParamValue(opts.organizerName)}` : ''; lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`); const attCn = opts.attendeeName ? `;CN=${icsParamValue(opts.attendeeName)}` : ''; lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`); lines.push('END:VEVENT'); lines.push('END:VCALENDAR'); const icsContent = lines.map(foldIcsLine).join('\r\n') + '\r\n'; debug.log('calendar', '[iMIP] Generated ICS:\n' + icsContent); const statusLabels: Record = { ACCEPTED: 'Accepted', TENTATIVE: 'Tentative', DECLINED: 'Declined', }; const statusLabel = statusLabels[opts.status] || opts.status; const subject = `${statusLabel}: ${opts.summary || 'Event'}`; debug.log('calendar', '[iMIP] identityId:', finalIdentityId); const emailId = `imip-reply-${Date.now()}`; const emailCreate: Record = { from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }], to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }], subject, keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { // RFC 6047 §3 requires multipart/mixed when a text/calendar part is present. // Using multipart/alternative causes most clients to ignore the iTIP method. // @see https://www.rfc-editor.org/rfc/rfc6047#section-3 // @see https://devguide.calconnect.org/iMIP/iMIPBest-Practices/ // Note: Gmail-to-Gmail events use Google's internal scheduling API, not iMIP. // This fix targets non-Gmail organizers and external CalDAV servers. type: 'multipart/mixed', subParts: [ { partId: 'text', type: 'text/plain' }, { partId: 'cal', type: 'text/calendar; method=REPLY; charset=UTF-8', disposition: 'inline', name: 'reply.ics' }, ], }, 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.getSubmissionAccountId(), create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, onSuccessUpdateEmail: { "#sub-1": { ...mailboxIdsReplacement(sentMailbox.id), "keywords/$draft": null, }, }, }, "1"], ]; debug.log('calendar', '[iMIP] Sending JMAP request with', methodCalls.length, 'method calls'); debug.log('calendar', '[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2)); const response = await this.request(methodCalls); debug.log('calendar', '[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2)); if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { if (methodName.endsWith('/error')) { debug.error('[iMIP] 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 }; debug.error('[iMIP] create error:', JSON.stringify(result.notCreated, null, 2)); throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply'); } } } debug.log('calendar', '[iMIP] sendImipReply completed successfully'); } /** * Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event. * * Fallback only: when `sendSchedulingMessages` is passed to CalendarEvent/set, * the server (Stalwart) queues the iTIP messages itself - calling this in * addition produces duplicate invitation emails. Note the generated ICS is * a minimal snapshot (no RRULE/VTIMEZONE), so server-side scheduling should * always be preferred. */ async sendImipInvitation(event: CalendarEvent): Promise { if (!event.participants) return; if (!event.uid) { debug.warn('calendar', '[iMIP] sendImipInvitation aborted: event has no UID', { eventId: event.id }); return; } const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { throw new Error('No sent mailbox found'); } const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); if (!draftsMailbox) { throw new Error('No drafts 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}`); } } // Prefer DURATION over DTEND (RFC 5545 §3.6.1): DTSTART above is a local // date-time (optionally with TZID) while event.utcEnd is a UTC instant - // emitting both mixed reference frames, and paired a floating DTSTART // with a UTC DTEND for events without a timezone. if (event.duration) { lines.push(`DURATION:${event.duration}`); } else 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:${escapeIcsText(event.title)}`); if (event.description) lines.push(`DESCRIPTION:${escapeIcsText(event.description)}`); if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`); if (event.status) lines.push(`STATUS:${event.status.toUpperCase()}`); const orgCn = organizerName ? `;CN=${icsParamValue(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=${icsParamValue(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.map(foldIcsLine).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 = { from: [{ name: organizerName || undefined, email: organizerEmail }], to: toAddresses, subject, keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { // See RFC 6047 §3: https://www.rfc-editor.org/rfc/rfc6047#section-3 type: 'multipart/mixed', subParts: [ { partId: 'text', type: 'text/plain' }, { partId: 'cal', type: 'text/calendar; method=REQUEST; charset=UTF-8', disposition: 'inline', name: 'invite.ics' }, ], }, 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.getSubmissionAccountId(), create: { "sub-1": { emailId: `#${emailId}`, identityId } }, onSuccessUpdateEmail: { "#sub-1": { ...mailboxIdsReplacement(sentMailbox.id), "keywords/$draft": null, }, }, }, "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. * * Fallback only: when `sendSchedulingMessages` is passed to the * CalendarEvent/set destroy, the server sends the iTIP CANCEL itself - * calling this in addition produces duplicate cancellation emails. */ async sendImipCancellation(event: CalendarEvent): Promise { if (!event.participants) return; if (!event.uid) { debug.warn('calendar', '[iMIP] sendImipCancellation aborted: event has no UID', { eventId: event.id }); return; } if (event.status && event.status !== 'cancelled') { debug.warn('calendar', 'sendImipCancellation called on non-cancelled event, status:', event.status); } const mailboxes = await this.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) { throw new Error('No sent mailbox found'); } const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); if (!draftsMailbox) { throw new Error('No drafts 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:${escapeIcsText(event.title)}`); if (event.sequence != null) lines.push(`SEQUENCE:${event.sequence}`); const orgCn = organizerName ? `;CN=${icsParamValue(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=${icsParamValue(attendee.name)}` : ''; lines.push(`ATTENDEE${cn}:mailto:${email}`); } lines.push('END:VEVENT'); lines.push('END:VCALENDAR'); const icsContent = lines.map(foldIcsLine).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 = { from: [{ name: organizerName || undefined, email: organizerEmail }], to: toAddresses, subject, keywords: { "$seen": true, "$draft": true }, mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { // See RFC 6047 §3: https://www.rfc-editor.org/rfc/rfc6047#section-3 type: 'multipart/mixed', subParts: [ { partId: 'text', type: 'text/plain' }, { partId: 'cal', type: 'text/calendar; method=CANCEL; charset=UTF-8', disposition: 'inline', name: 'cancel.ics' }, ], }, 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.getSubmissionAccountId(), create: { "sub-1": { emailId: `#${emailId}`, identityId } }, onSuccessUpdateEmail: { "#sub-1": { ...mailboxIdsReplacement(sentMailbox.id), "keywords/$draft": null, }, }, }, "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'); } } } } private xhrUpload( url: string, file: File, onProgress?: (loaded: number, total: number) => void, signal?: AbortSignal, ): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new DOMException('Upload aborted', 'AbortError')); return; } const xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream'); xhr.setRequestHeader('Authorization', this.authHeader); xhr.responseType = 'text'; const onAbort = () => xhr.abort(); if (signal) signal.addEventListener('abort', onAbort, { once: true }); const cleanup = () => signal?.removeEventListener('abort', onAbort); if (onProgress) { // Fire 0% immediately so the UI leaves its initial state even // before the first network packet flushes. onProgress(0, file.size); xhr.upload.onprogress = (ev) => { // ev.total is only meaningful when lengthComputable; fall back // to file.size so callers always get a usable denominator. const total = ev.lengthComputable ? ev.total : file.size; onProgress(ev.loaded, total); }; } xhr.onload = () => { cleanup(); if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.responseText); } else { reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`)); } }; xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); }; xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); }; xhr.send(file); }); } // Signature accepts either the legacy positional accountId string OR // the options bag introduced for progress / signal so existing // call-sites keep compiling without touching every plugin. async uploadBlob( file: File, optsOrAccountId?: | string | { accountId?: string; onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal; }, ): Promise<{ blobId: string; size: number; type: string }> { const opts = typeof optsOrAccountId === 'string' ? { accountId: optsOrAccountId } : optsOrAccountId ?? {}; 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 targetAccountId = opts.accountId || this.accountId; const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId)); // XHR path: fetch() does not expose upload progress events, so when the // caller wants progress (or an AbortSignal) we use XMLHttpRequest. The // fetch path is kept for callers that don't need either, to preserve // existing 401/retry behaviour through authenticatedFetch(). let responseText: string; if (opts.onProgress || opts.signal) { responseText = await this.xhrUpload( finalUploadUrl, file, opts.onProgress, opts.signal, ); } else { 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}`); } 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[targetAccountId]; 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'); } /** * Import a raw RFC822 message (referenced by a previously-uploaded blob) into * one or more mailboxes. Returns the new email id. Used for sending MDNs, * where the exact MIME bytes must be preserved (Email/set can't express a * multipart/report report-type parameter reliably). */ async importEmail( blobId: string, mailboxIds: Record, keywords?: Record, accountId?: string ): Promise { const targetAccountId = accountId || this.accountId; const creationId = `imp-${Date.now()}`; const response = await this.request([ ["Email/import", { accountId: targetAccountId, emails: { [creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } }, }, }, "0"], ]); const res = response.methodResponses?.[0]; if (res?.[0] !== "Email/import") { console.error('Email/import: unexpected response', res); return null; } const payload = res[1] as { created?: Record; notCreated?: Record; }; const created = payload?.created?.[creationId]; if (!created) { const reason = payload?.notCreated?.[creationId]; console.error('Email/import failed:', reason || payload); throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`); } return created.id; } /** * Send an RFC 8098 Message Disposition Notification (read receipt) in reply * to a message that carried a Disposition-Notification-To header. Builds the * multipart/report, uploads it as a blob, imports it into Sent, then submits * it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the * requesting address). */ async sendReadReceipt(params: { to: string; fromEmail: string; fromName?: string; identityId: string; originalMessageId?: string | string[]; originalSubject?: string; originalRecipient?: string; automatic?: boolean; accountId?: string; subject?: string; humanText?: string; }): Promise { const targetAccountId = params.accountId || this.accountId; const { buildMdnMessage } = await import("@/lib/mdn"); const raw = buildMdnMessage(params); const file = new File([raw], "receipt.eml", { type: "message/rfc822" }); const { blobId } = await this.uploadBlob(file); const mailboxes = await this.getMailboxes(); const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0]; if (!targetMailbox) throw new Error('No mailbox available for MDN import'); const emailId = await this.importEmail( blobId, { [targetMailbox.id]: true }, { "$seen": true }, targetAccountId ); if (!emailId) throw new Error('MDN import failed'); const subId = `mdnsub-${Date.now()}`; const response = await this.request([ ["EmailSubmission/set", { accountId: targetAccountId, create: { [subId]: { emailId, identityId: params.identityId, envelope: { mailFrom: { email: params.fromEmail }, rcptTo: [{ email: params.to }], }, }, }, }, "0"], ]); const subRes = response.methodResponses?.[0]; const notCreated = (subRes?.[1] as { notCreated?: Record })?.notCreated?.[subId]; if (notCreated) { throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`); } } getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string { if (!this.downloadUrl) { throw new Error('Download URL not available. Please reconnect.'); } // RFC 6570 level 1 URI template expansion. Blobs are scoped per account, // so a caller fetching a blob from a delegated/shared account must pass // that owner's accountId rather than defaulting to the primary one. return this.downloadUrl .replace('{accountId}', encodeURIComponent(accountId || 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, accountId?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type, accountId); 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, accountId?: string): Promise { const blob = await this.fetchBlob(blobId, name, type, accountId); return URL.createObjectURL(blob); } getCapabilities(): Record { return this.capabilities; } hasCapability(capability: string): boolean { return capability in this.capabilities; } /** Check whether a capability is present on the primary account. */ hasAccountCapability(capability: string, accountId?: string): boolean { const id = accountId || this.accountId; const caps = this.session?.accounts?.[id]?.accountCapabilities; return !!caps && capability in caps; } 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; } getMaxObjectsInSet(): number { const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInSet?: number } | undefined; return coreCapability?.maxObjectsInSet || 500; } getMaxDelayedSend(accountId?: string): number { const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend; return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0; } hasDelayedSend(accountId?: string): boolean { const submissionCapability = this.getSubmissionCapability(accountId); const submissionExtensions = submissionCapability?.submissionExtensions; const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE'); return !!submissionCapability && hasFutureRelease && this.getMaxDelayedSend(accountId) > 0; } private validateDelayedUntil(delayedUntil: string, accountId?: string): number { const time = new Date(delayedUntil).getTime(); if (!Number.isFinite(time)) { throw new Error('Scheduled send time is invalid'); } const now = Date.now(); if (time <= now) { throw new Error('Scheduled send time must be in the future'); } const maxDelayedSend = this.getMaxDelayedSend(accountId); if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) { throw new Error('Scheduled send is not supported for this account'); } if (time > now + maxDelayedSend * 1000) { throw new Error('Scheduled send time is later than the server allows'); } return Math.ceil((time - now) / 1000); } private async getEmailSubmissionSendAt(submissionId: string): Promise { const response = await this.request([ ['EmailSubmission/get', { accountId: this.getSubmissionAccountId(), ids: [submissionId], properties: ['sendAt', 'undoStatus'], }, '0'], ]); const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined; return submission?.sendAt; } private async getEmailSubmissionEnvelope(submissionId: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> { const response = await this.request([ ['EmailSubmission/get', { accountId: this.getSubmissionAccountId(), ids: [submissionId], properties: ['envelope'], }, '0'], ]); const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined; return submission?.envelope; } private getSubmissionAccountId(accountId?: string): string { // The requested (mail) account may not host EmailSubmission objects — JMAP // allows submission to live in a separate account (session // primaryAccounts['…:submission']). Only honour the requested account when // it actually advertises the submission capability; otherwise fall back to // the account JMAP designates for submission. const submissionPrimary = this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission']; if (accountId && this.session?.accounts?.[accountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission']) { return accountId; } return submissionPrimary || accountId || this.accountId; } private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined { const submissionAccountId = this.getSubmissionAccountId(accountId); return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined; } private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean { const target = extension.toUpperCase(); if (Array.isArray(submissionExtensions)) { return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target); } if (submissionExtensions && typeof submissionExtensions === 'object') { return Object.entries(submissionExtensions as Record) .some(([key, value]) => key.toUpperCase() === target && value !== false && value != null); } return false; } 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; } /** * RFC 8887 (JMAP over WebSocket) push endpoint, advertised under the * `urn:ietf:params:jmap:websocket` capability (not a root session field * like eventSourceUrl - it's nested the same way every other JMAP * extension capability is). Rewritten to the client's own server host in * rewriteSessionUrls() at connect time, same reasoning as apiUrl/ * downloadUrl/eventSourceUrl. Returns null for servers that don't * advertise it - callers fall back to SSE/polling. */ getWebSocketUrl(): string | null { const wsCapability = this.capabilities["urn:ietf:params:jmap:websocket"] as | { url?: string; supportsPush?: boolean } | undefined; return wsCapability?.url || null; } getAccountId(): string { return this.accountId; } getUsername(): string { return this.username || this.session?.accounts?.[this.accountId]?.name || ''; } // Server-confirmed authenticated login from the JMAP Session object. Use // this (not getUsername(), which echoes the constructor arg, nor the // sending identity) to verify a slot's token resolved to the expected // account. getSessionUsername(): string | undefined { return this.session?.username; } 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"); } supportsPrincipals(): boolean { return this.hasCapability("urn:ietf:params:jmap:principals"); } 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"]; } /** * Accounts (primary + shared/group) visible in this session, each tagged with * the capabilities it advertises. Unifies the per-feature enumeration that * getCalendarCapableAccountIds()/getContactCapableAccountIds()/ * getFilesCapableAccountIds() do: a non-primary account is included when it * advertises a capability OR is a non-personal (shared/group) account, since * Stalwart doesn't always advertise capabilities on those even when they * support the feature. Primary account first; name from the session account * (primary falls back to the username). Drives the settings "Shared with me" * list and the scoped-settings tab gating. */ getSharedAccounts(): SharedAccount[] { const primaryId = this.getSieveAccountId(); const toEntry = (id: string, isPrimary: boolean): SharedAccount => { const account = this.accounts[id]; const caps = account?.accountCapabilities; const shared = !account?.isPersonal; return { id, name: account?.name || (isPrimary ? (this.username || id) : id), isPrimary, capabilities: { mail: !!caps?.["urn:ietf:params:jmap:mail"] || shared, sieve: !!caps?.["urn:ietf:params:jmap:sieve"] || shared, calendars: !!caps?.["urn:ietf:params:jmap:calendars"] || shared, contacts: !!caps?.["urn:ietf:params:jmap:contacts"] || shared, filenode: !!caps?.["urn:ietf:params:jmap:filenode"] || shared, }, }; }; const result = [toEntry(primaryId, true)]; for (const id of Object.keys(this.accounts)) { if (id === primaryId) continue; const account = this.accounts[id]; // Mirror the per-feature helpers: include any non-personal account, plus // accounts that advertise at least one editable capability. const caps = account.accountCapabilities; const advertisesAny = !!caps?.["urn:ietf:params:jmap:mail"] || !!caps?.["urn:ietf:params:jmap:sieve"] || !!caps?.["urn:ietf:params:jmap:calendars"] || !!caps?.["urn:ietf:params:jmap:contacts"] || !!caps?.["urn:ietf:params:jmap:filenode"]; if (!account.isPersonal || advertisesAny) { result.push(toEntry(id, false)); } } return result; } /** * Sieve-capable accounts (primary + shared/group), for the filters UI. * Thin wrapper over getSharedAccounts() filtered to the sieve capability. */ getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] { return this.getSharedAccounts() .filter((a) => a.isPrimary || a.capabilities.sieve) .map(({ id, name, isPrimary }) => ({ id, name, isPrimary })); } getSieveCapabilities(accountId?: string): SieveCapabilities | null { const sieveAccountId = accountId || 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(accountId?: string): Promise { const response = await this.request([ ["SieveScript/get", { accountId: 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, accountId?: string): Promise { // Blobs are scoped per account, so a shared/group account's script must be // downloaded against that owner's accountId, not the primary one. const url = this.getBlobDownloadUrl( blobId, 'script.sieve', 'application/sieve', accountId || this.getSieveAccountId(), ); 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, accountId?: string): Promise { if (!this.session?.uploadUrl) { throw new Error('Upload URL not available'); } const targetAccountId = accountId || this.getSieveAccountId(); const uploadUrl = this.session.uploadUrl.replace( '{accountId}', encodeURIComponent(targetAccountId) ); 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[targetAccountId]; if (blobInfo?.blobId) return blobInfo.blobId; throw new Error('Invalid upload response: blobId not found'); } async createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise { const targetAccountId = accountId || this.getSieveAccountId(); const blobId = await this.uploadSieveBlob(content, targetAccountId); const setArgs: Record = { accountId: targetAccountId, 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(targetAccountId); 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, accountId?: string): Promise { const targetAccountId = accountId || this.getSieveAccountId(); const blobId = await this.uploadSieveBlob(content, targetAccountId); const setArgs: Record = { accountId: targetAccountId, 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, accountId?: string): Promise { const response = await this.request([ ["SieveScript/set", { accountId: accountId || this.getSieveAccountId(), 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, accountId?: string): Promise { const response = await this.request([ ["SieveScript/set", { accountId: accountId || this.getSieveAccountId(), 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(accountId?: string): Promise { const response = await this.request([ ["SieveScript/set", { accountId: accountId || this.getSieveAccountId(), 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, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> { const targetAccountId = accountId || this.getSieveAccountId(); const blobId = await this.uploadSieveBlob(content, targetAccountId); const response = await this.request([ ["SieveScript/validate", { accountId: targetAccountId, 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[] { const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"]; if (this.hasCapability("urn:ietf:params:jmap:principals")) { using.push("urn:ietf:params:jmap:principals:owner"); } return using; } private calendarUsing(): string[] { const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"]; if (this.hasCapability("urn:ietf:params:jmap:principals")) { using.push("urn:ietf:params:jmap:principals:owner"); } return using; } 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 { try { const accountId = this.getContactsAccountId(); const response = await this.request([ ["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "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 { 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, properties: ADDRESS_BOOK_PROPERTIES }, "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(); } } async createAddressBook(name: string): Promise { const accountId = this.getContactsAccountId(); const response = await this.request([ ["AddressBook/set", { accountId, create: { "new-book": { name } }, }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "AddressBook/set") { const result = response.methodResponses[0][1]; const created = result.created?.["new-book"]; if (created) { return { id: created.id, name, ...created } as AddressBook; } const err = result.notCreated?.["new-book"]; throw new Error(err?.description || "Failed to create address book"); } throw new Error("Failed to create address book"); } async updateAddressBook(addressBookId: string, updates: Partial, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getContactsAccountId(); // Only forward server-settable properties const { name, description, sortOrder, isDefault, color } = updates as Record; const patch: Record = {}; if (name !== undefined) patch.name = name; if (description !== undefined) patch.description = description; if (sortOrder !== undefined) patch.sortOrder = sortOrder; if (isDefault !== undefined) patch.isDefault = isDefault; if (color !== undefined) patch.color = color; const response = await this.request([ ["AddressBook/set", { accountId, update: { [addressBookId]: patch }, }, "0"] ], this.contactUsing()); if (response.methodResponses?.[0]?.[0] === "AddressBook/set") { const result = response.methodResponses[0][1]; if (result.notUpdated?.[addressBookId]) { const error = result.notUpdated[addressBookId]; throw new Error(error.description || "Failed to update address book"); } return; } throw new Error("Failed to update address book"); } async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getContactsAccountId(); const response = await this.request([ ["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"], ], this.contactUsing()); const result = response.methodResponses?.[0]?.[1]; if (result?.notDestroyed?.[addressBookId]) { const err = result.notDestroyed[addressBookId]; throw new Error(err.description || "Failed to delete address book"); } } // ── Sharing (RFC 9670) ────────────────────────────────────────────────────── private principalsUsing(): string[] { return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"]; } /** * List all principals visible to the user (RFC 9670). Stalwart returns the * full directory regardless of `filter`, so we fetch the whole list and let * callers filter client-side. */ async getPrincipals(targetAccountId?: string): Promise { if (!this.supportsPrincipals()) return []; const accountId = targetAccountId || this.accountId; try { const response = await this.request([ ["Principal/query", { accountId }, "0"], ["Principal/get", { accountId, "#ids": { resultOf: "0", name: "Principal/query", path: "/ids" }, }, "1"], ], this.principalsUsing()); const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get"); if (!getResp) return []; const list = (getResp[1].list || []) as Principal[]; return list.map((p) => ({ ...p, accountId })); } catch (error) { console.error("Failed to fetch principals:", error); return []; } } /** * Add, update, or remove a principal's rights on a calendar. * Pass `rights: null` to revoke access. */ async setCalendarShare( calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string, ): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { accountId, update: { [calendarId]: { [`shareWith/${principalId}`]: rights } }, }, "0"], ], this.calendarUsing()); const result = response.methodResponses?.[0]?.[1]; if (result?.notUpdated?.[calendarId]) { const err = result.notUpdated[calendarId]; throw new Error(err.description || "Failed to update calendar share"); } if (!result?.updated || !(calendarId in result.updated)) { throw new Error("Server did not confirm the share update"); } } /** * Add, update, or remove a principal's rights on an address book. * Pass `rights: null` to revoke access. */ async setAddressBookShare( addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string, ): Promise { const accountId = targetAccountId || this.getContactsAccountId(); const response = await this.request([ ["AddressBook/set", { accountId, update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } }, }, "0"], ], this.contactUsing()); const result = response.methodResponses?.[0]?.[1]; if (result?.notUpdated?.[addressBookId]) { const err = result.notUpdated[addressBookId]; throw new Error(err.description || "Failed to update address book share"); } if (!result?.updated || !(addressBookId in result.updated)) { throw new Error("Server did not confirm the share update"); } } /** * Add, update, or remove a principal's rights on a mailbox folder. * Pass `rights: null` to revoke access. */ async setMailboxShare( mailboxId: string, principalId: string, rights: MailboxRights | null, targetAccountId?: string, ): Promise { const accountId = targetAccountId || this.accountId; const response = await this.request([ ["Mailbox/set", { accountId, update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } }, }, "0"], ]); const result = response.methodResponses?.[0]?.[1]; if (result?.notUpdated?.[mailboxId]) { const err = result.notUpdated[mailboxId]; throw new Error(err.description || "Failed to update mailbox share"); } if (!result?.updated || !(mailboxId in result.updated)) { throw new Error("Server did not confirm the share update"); } } private async fetchPaginatedContacts( accountId: string, filter?: Record, ): Promise { const batchSize = this.getMaxObjectsInGet(); const allIds: string[] = []; let position = 0; // Paginate ContactCard/query to collect all IDs for (;;) { const queryArgs: Record = { 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 { 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 { 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 { 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, targetAccountId?: string): Promise { 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, // Stalwart stores the card without one if omitted (#644) uid: contactData.uid || `urn:uuid:${crypto.randomUUID()}`, 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, targetAccountId?: string): Promise { 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 { 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 { 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 { try { const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "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 { try { const allCalendars: Calendar[] = []; const primaryId = this.getCalendarsAccountId(); const accountIds = this.getCalendarCapableAccountIds(); for (const accountId of accountIds) { const isPrimary = accountId === primaryId; if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue; const account = this.accounts[accountId]; try { const response = await this.request([ ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "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, targetAccountId?: string): Promise { 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], properties: CALENDAR_PROPERTIES }, "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, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); // Stalwart rejects the whole update with invalidProperties if any key is // not settable (e.g. id, isDefault, myRights, or client-only fields), so // only forward the properties its Calendar/set actually accepts. Keys // containing '/' are JSON-pointer patches; keep those whose root segment // is settable (shareWith/..., defaultAlertsWithTime/...). const cleanUpdates: Record = {}; for (const [key, value] of Object.entries(updates as Record)) { const root = key.split('/', 1)[0]; if (CALENDAR_SETTABLE_PROPERTIES.has(root)) { cleanUpdates[key] = value; } } const response = await this.request([ ["Calendar/set", { accountId, update: { [calendarId]: cleanUpdates } }, "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"); } /** * Mark a calendar as the account default. `isDefault` is read-only in * Stalwart's Calendar/set - the default is changed via the * `onSuccessSetIsDefault` request argument instead. */ async setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const response = await this.request([ ["Calendar/set", { accountId, onSuccessSetIsDefault: calendarId }, "0"] ], this.calendarUsing()); const methodName = response.methodResponses?.[0]?.[0]; if (methodName === "error") { const error = response.methodResponses?.[0]?.[1]; throw new Error(error?.description || error?.type || "Failed to set default calendar"); } if (methodName !== "Calendar/set") { throw new Error("Failed to set default calendar"); } } async deleteCalendar(calendarId: string, targetAccountId?: string): Promise { 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 { const accountId = targetAccountId || this.getCalendarsAccountId(); const GET_BATCH_SIZE = this.getMaxObjectsInGet(); const timeZone = getUserTimeZone(); const queryArgs: Record = { accountId, limit: 1000 }; if (timeZone) { queryArgs.timeZone = timeZone; } if (calendarIds && calendarIds.length > 0) { queryArgs.filter = buildInCalendarFilter(calendarIds); } // First, query to get all IDs const queryResponse = await this.request([ ["CalendarEvent/query", queryArgs, "0"], ], this.calendarUsing()); // Check for JMAP method-level errors if (queryResponse.methodResponses?.[0]?.[0] === "error") { const error = queryResponse.methodResponses[0][1]; throw new Error(error?.description || error?.type || "CalendarEvent/query failed"); } const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || []; if (ids.length === 0) return []; // Batch the /get calls to stay within server max-objects limit const allEvents: CalendarEvent[] = []; for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) { const batchIds = ids.slice(i, i + GET_BATCH_SIZE); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: batchIds, ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[]; allEvents.push(...events); } } return allEvents .filter((event) => !isTaskObject(event)) .map((event) => normalizeCalendarEventLike(event)); } // Shared accounts the server rejected calendar access for - probed once, // then skipped for the rest of the session (see getCalendarCapableAccountIds // for why the fan-out has to probe on suspicion). private calendarAccessDenied = new Set(); async queryAllCalendarEvents( filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, accountId?: string ): Promise { try { const allEvents: CalendarEvent[] = []; const primaryId = this.getCalendarsAccountId(); const accountIds = accountId ? [accountId] : this.getCalendarCapableAccountIds(); for (const accountId of accountIds) { const isPrimary = accountId === primaryId; if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue; 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 { try { const accountId = targetAccountId || this.getCalendarsAccountId(); const timeZone = getUserTimeZone(); const queryArgs: Record = { accountId, filter, limit: limit || 1000, }; // Interpret the LocalDateTime after/before filter values in the user's // time zone (Stalwart defaults to UTC, shifting range boundaries). if (timeZone) { queryArgs.timeZone = timeZone; } // NOTE: We do NOT use expandRecurrences because Stalwart returns synthetic // IDs that cannot be used for CalendarEvent/set (update/destroy). // Recurrence expansion is done client-side instead. if (sort) { queryArgs.sort = sort; } const GET_BATCH_SIZE = this.getMaxObjectsInGet(); // First, query to get IDs const queryResponse = await this.request([ ["CalendarEvent/query", queryArgs, "0"], ], this.calendarUsing()); if (queryResponse.methodResponses?.[0]?.[0] === "error") { const error = queryResponse.methodResponses[0][1]; // Keep the JMAP error type so the catch below can tell an expected // access rejection apart from a genuine failure. throw Object.assign( new Error(error?.description || error?.type || "CalendarEvent/query failed"), { jmapErrorType: error?.type }, ); } const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || []; if (ids.length === 0) return []; // Batch the /get calls to stay within server max-objects limit const allEvents: CalendarEvent[] = []; for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) { const batchIds = ids.slice(i, i + GET_BATCH_SIZE); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: batchIds, ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[]; allEvents.push(...events); } } const filtered = allEvents .filter((event) => !isTaskObject(event)) .map((event) => normalizeCalendarEventLike(event)); const eventsWithParticipants = filtered.filter(e => e.participants && Object.keys(e.participants).length > 0); debug.log('calendar', 'queryCalendarEvents participant summary', { totalEvents: filtered.length, eventsWithParticipants: eventsWithParticipants.length, details: eventsWithParticipants.map(e => ({ id: e.id, title: e.title, participantCount: Object.keys(e.participants!).length, participants: e.participants, replyTo: e.replyTo, })), }); return filtered; } catch (error) { // The fan-out over shared accounts probes on suspicion (see // getCalendarCapableAccountIds) and may hit accounts that grant no // calendar access at all. Remember the rejection and go quiet instead // of re-probing - and re-logging - on every range change. const type = (error as { jmapErrorType?: string } | null)?.jmapErrorType; const denied = type === 'forbidden' || type === 'accountNotFound' || /not have access/i.test(error instanceof Error ? error.message : ''); if (targetAccountId && denied) { this.calendarAccessDenied.add(targetAccountId); debug.log('calendar', `No calendar access to account ${targetAccountId} - skipping it from now on`); return []; } console.error('Failed to query calendar events:', error); return []; } } async getCalendarEvent(id: string, targetAccountId?: string): Promise { try { const accountId = targetAccountId || this.getCalendarsAccountId(); const timeZone = getUserTimeZone(); const response = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: [id], ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") { const list = response.methodResponses[0][1].list || []; return list[0] ? normalizeCalendarEventLike(list[0] as CalendarEvent) : null; } return null; } catch (error) { console.error('Failed to get calendar event:', error); return null; } } async createCalendarEvent(event: Partial, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise { 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; cleanRecurrenceRules(cleanEvent as unknown as Record); debug.group('CalendarEvent/create', 'calendar'); debug.log('calendar', 'CalendarEvent/create outgoing payload', { accountId, sendSchedulingMessages, eventKeys: Object.keys(cleanEvent), hasParticipants: !!cleanEvent.participants, participantCount: cleanEvent.participants ? Object.keys(cleanEvent.participants).length : 0, participants: cleanEvent.participants || null, replyTo: cleanEvent.replyTo || null, }); const setArgs: Record = { accountId, create: { "new-event": cleanEvent } }; if (sendSchedulingMessages !== undefined) { setArgs.sendSchedulingMessages = sendSchedulingMessages; } const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); debug.log('calendar', 'CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (result.notCreated?.["new-event"]) { const error = result.notCreated["new-event"]; debug.warn('calendar', 'CalendarEvent/create notCreated', error); debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties); debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent)); debug.groupEnd(); throw new Error(error.description || "Failed to create calendar event"); } const createdId = result.created?.["new-event"]?.id; debug.log('calendar', 'CalendarEvent/create server acknowledged created id', { createdId, created: result.created?.['new-event'] || null, }); if (createdId) { const created = await this.getCalendarEvent(createdId, targetAccountId); debug.log('calendar', 'CalendarEvent/create fetched created event', { ...getCalendarEventDebugSnapshot(created), hasParticipants: !!created?.participants, participantCount: created?.participants ? Object.keys(created.participants).length : 0, participants: created?.participants || null, replyTo: created?.replyTo || null, }); if (created?.uid) { try { const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId); debug.log('calendar', 'CalendarEvent/create verification query by uid', { uid: created.uid, matchCount: verificationMatches.length, matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)), }); } catch (verificationError) { debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError); } } if (created) { debug.groupEnd(); return created; } debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', { createdId, targetAccountId, }); } } debug.groupEnd(); throw new Error("Failed to create calendar event"); } /** * Batch-create multiple calendar events in a single JMAP request. * Returns arrays of successfully created events and failed creation keys. */ async batchCreateCalendarEvents( events: Partial[], targetAccountId?: string, ): Promise<{ created: CalendarEvent[]; failed: string[] }> { if (events.length === 0) return { created: [], failed: [] }; const accountId = targetAccountId || this.getCalendarsAccountId(); debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId }); const createdIds: string[] = []; const failed: string[] = []; const indexed = events.map((event, index) => ({ event, index })); for (const batch of batched(indexed, this.getMaxObjectsInSet())) { // Build the create map: { "new-0": event0, "new-1": event1, ... } const createMap: Record> = {}; for (const { event, index } of batch) { const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent; cleanRecurrenceRules(clean as unknown as Record); createMap[`new-${index}`] = clean; } // Never emit iMIP scheduling messages when importing. Imported events often // carry an organizer/participants where the current user is the organizer; // without this, Stalwart tries to send invitation emails to every attendee // synchronously during CalendarEvent/set, which is both wrong (importing a // calendar should not spam invites) and can block the request indefinitely, // leaving the import spinner spinning forever (#411). const response = await this.request([ ["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; for (const { index } of batch) { const key = `new-${index}`; if (result.created?.[key]?.id) { createdIds.push(result.created[key].id); } else if (result.notCreated?.[key]) { debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]); failed.push(key); } } } } if (createdIds.length === 0) { return { created: [], failed }; } // Fetch the created events back for their server-assigned properties const refetchTimeZone = getUserTimeZone(); const createdEvents: CalendarEvent[] = []; for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) { const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_EVENT_PROPERTIES], ids: batchIds, ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), }, "0"] ], this.calendarUsing()); if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { const list = getResponse.methodResponses[0][1].list || []; createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e))); } } debug.log('calendar', 'CalendarEvent/batchCreate result', { requested: events.length, created: createdEvents.length, failed: failed.length, }); return { created: createdEvents, failed }; } async updateCalendarEvent( eventId: string, updates: Partial, sendSchedulingMessages?: boolean, targetAccountId?: string ): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); // Strip client-only and server-immutable fields before sending to JMAP const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent; cleanRecurrenceRules(cleanUpdates as unknown as Record); const setArgs: Record = { accountId, update: { [eventId]: cleanUpdates } }; if (sendSchedulingMessages !== undefined) { setArgs.sendSchedulingMessages = sendSchedulingMessages; } debug.log('calendar', 'CalendarEvent/set update request', { eventId, accountId, cleanUpdateKeys: Object.keys(cleanUpdates), sendSchedulingMessages, hasParticipants: !!cleanUpdates.participants, participantCount: cleanUpdates.participants ? Object.keys(cleanUpdates.participants).length : 0, participants: cleanUpdates.participants || null, replyTo: (cleanUpdates as Record).replyTo || null, }); const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); const methodName = response.methodResponses?.[0]?.[0]; const result = response.methodResponses?.[0]?.[1]; if (methodName === "error") { const errorType = result?.type || 'unknown'; const errorDesc = result?.description || ''; debug.error('CalendarEvent/set update returned JMAP error', { type: errorType, description: errorDesc }); throw new Error(`JMAP error (${errorType}): ${errorDesc}`); } if (methodName === "CalendarEvent/set") { if (result.notUpdated?.[eventId]) { const error = result.notUpdated[eventId]; debug.error('CalendarEvent/set notUpdated', { eventId, error }); throw new Error(error.description || "Failed to update calendar event"); } debug.log('calendar', 'CalendarEvent/set update full response', { methodName, result }); debug.log('calendar', 'CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null }); return; } debug.error('CalendarEvent/set update unexpected response', { methodName, result }); throw new Error("Failed to update calendar event"); } async parseCalendarEvents(accountId: string, blobId: string): Promise[]> { 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]) .map((event) => normalizeCalendarEventLike(event as Partial)); } return []; } throw new Error("Failed to parse calendar file"); } async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const setArgs: Record = { accountId, destroy: [eventId] }; if (sendSchedulingMessages !== undefined) { setArgs.sendSchedulingMessages = sendSchedulingMessages; } debug.log('calendar', 'CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages }); const response = await this.request([ ["CalendarEvent/set", setArgs, "0"] ], this.calendarUsing()); const methodName = response.methodResponses?.[0]?.[0]; const result = response.methodResponses?.[0]?.[1]; if (methodName === "error") { const errorType = result?.type || 'unknown'; const errorDesc = result?.description || ''; debug.error('CalendarEvent/set destroy returned JMAP error', { type: errorType, description: errorDesc }); throw new Error(`JMAP error (${errorType}): ${errorDesc}`); } if (methodName === "CalendarEvent/set") { if (result.notDestroyed?.[eventId]) { const error = result.notDestroyed[eventId]; debug.error('CalendarEvent/set notDestroyed', { eventId, error }); throw new Error(error.description || "Failed to delete calendar event"); } debug.log('calendar', 'CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed }); return; } debug.error('CalendarEvent/set destroy unexpected response', { methodName, result }); 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 destroyed: string[] = []; const notDestroyed: string[] = []; for (const batch of batched(eventIds, this.getMaxObjectsInSet())) { const response = await this.request([ ["CalendarEvent/set", { accountId, destroy: batch }, "0"] ], this.calendarUsing()); 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 }; } // ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ─── /** * Fetch all Task objects via the CalendarEvent endpoints. * * Stalwart has no `types` filter on CalendarEvent/query (it fails the whole * query with `unsupportedFilter`), and the previous CalendarEvent/get * ids:null fallback was capped at the server's maxObjectsInGet (500 by * default), silently hiding tasks in larger accounts. Instead, page through * CalendarEvent/query (which returns events *and* tasks), fetch in * /get-sized batches, and detect tasks client-side. */ async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); debug.group('CalendarTask/fetch', 'tasks'); debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' }); try { // Page through the query to collect all object ids. const QUERY_PAGE = 1000; const MAX_IDS = 50000; // safety bound const timeZone = getUserTimeZone(); const ids: string[] = []; for (let position = 0; position < MAX_IDS;) { const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; if (timeZone) { queryArgs.timeZone = timeZone; } if (calendarIds && calendarIds.length > 0) { queryArgs.filter = buildInCalendarFilter(calendarIds); } const response = await this.request([ ["CalendarEvent/query", queryArgs, "0"], ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "error") { const error = response.methodResponses[0][1]; debug.warn('tasks', 'CalendarTask/fetch query failed', error); debug.groupEnd(); return []; } const pageIds: string[] = response.methodResponses?.[0]?.[1]?.ids || []; ids.push(...pageIds); if (pageIds.length < QUERY_PAGE) break; position += pageIds.length; } debug.log('tasks', 'CalendarTask/fetch query returned', ids.length, 'object ids'); if (ids.length === 0) { debug.groupEnd(); return []; } // Fetch the objects in batches that respect the server's /get limit. const GET_BATCH_SIZE = this.getMaxObjectsInGet(); const allObjects: Record[] = []; for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) { const batchIds = ids.slice(i, i + GET_BATCH_SIZE); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_TASK_PROPERTIES], ids: batchIds, ...(timeZone ? { timeZone } : {}), }, "0"] ], this.calendarUsing()); if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { allObjects.push(...(getResponse.methodResponses[0][1].list || [])); } } const tasks: CalendarTask[] = []; for (const obj of allObjects) { const type = obj['@type']; const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task'; // CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type // set to something other than 'Event'. Detect them by the presence of // task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2 // defines as Task-only - a VEVENT will never include them in the response. // We check for key presence (even if null) because Stalwart may return null // instead of the RFC defaults (e.g. progress default is "needs-action"). // @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2 const hasTaskFields = ('due' in obj) || ('progress' in obj) || ('percentComplete' in obj); const isCalDavTask = type !== 'Event' && hasTaskFields; if (!isExplicitTask && !isCalDavTask) continue; tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask); } debug.log('tasks', 'CalendarTask/fetch complete,', tasks.length, 'tasks of', allObjects.length, 'objects'); debug.groupEnd(); return tasks; } catch (error) { debug.error('CalendarTask/fetch failed', error); debug.groupEnd(); return []; } } async createCalendarTask(task: Partial, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); const { '@type': _type, ...taskData } = task; const cleanTask = { ...taskData, '@type': 'Task' }; debug.group('CalendarTask/create', 'tasks'); debug.log('tasks', 'CalendarTask/create accountId', accountId); debug.log('tasks', 'CalendarTask/create outgoing payload', cleanTask); const response = await this.request([ ["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: { "new-task": cleanTask }, }, "0"] ], this.calendarUsing()); const result = response.methodResponses?.[0]?.[1]; debug.log('tasks', 'CalendarTask/create raw set response', result); if (result?.notCreated?.["new-task"]) { const error = result.notCreated["new-task"]; debug.warn('tasks', 'CalendarTask/create REJECTED by server', error); debug.groupEnd(); throw new Error(error.description || "Failed to create task"); } const createdId = result?.created?.["new-task"]?.id; const serverCreated = result?.created?.["new-task"]; debug.log('tasks', 'CalendarTask/create server acknowledged', { createdId, serverCreated }); if (!createdId) { debug.warn('tasks', 'CalendarTask/create no id in server response'); debug.groupEnd(); throw new Error("Failed to create task - no id returned"); } // Fetch back with task-specific properties debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] }); const refetchTimeZone = getUserTimeZone(); const getResponse = await this.request([ ["CalendarEvent/get", { accountId, properties: [...CALENDAR_TASK_PROPERTIES], ids: [createdId], ...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}), }, "0"] ], this.calendarUsing()); if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") { const list = getResponse.methodResponses[0][1].list || []; const notFound = getResponse.methodResponses[0][1].notFound || []; debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound }); if (list[0]) { const created = { ...list[0], '@type': 'Task' as const } as CalendarTask; debug.log('tasks', 'CalendarTask/create final task object', { id: created.id, uid: created.uid, '@type': created['@type'], title: created.title, due: created.due, start: created.start, progress: created.progress, showWithoutTime: created.showWithoutTime, calendarIds: created.calendarIds, }); debug.groupEnd(); return created; } } debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId); debug.groupEnd(); throw new Error("Failed to fetch created task"); } async updateCalendarTask(taskId: string, updates: Partial, targetAccountId?: string): Promise { await this.updateCalendarEvent(taskId, updates as unknown as Partial, false, targetAccountId); } async deleteCalendarTask(taskId: string, targetAccountId?: string): Promise { await this.deleteCalendarEvent(taskId, false, targetAccountId); } // ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ─── supportsFiles(accountId?: string): boolean { // Gate on the ACCOUNT capability, not the server-wide session capability. // A server can advertise urn:ietf:params:jmap:filenode while a specific // account has its jmap-file-node-* permissions revoked, in which case the // capability is absent from that account's accountCapabilities and every // FileNode action fails with an authorization error (#563). Mirror // getFilesCapableAccountIds(): non-personal (shared/group) accounts don't // always advertise per-account, so treat those as capable. const id = accountId || this.accountId; const account = this.accounts[id]; if (!account) return false; return !!account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal; } async probeFileNodeSupport(): Promise { // 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 the server advertises FileNode server-wide but this account's // accountCapabilities omits it, that's an explicit per-account denial (#563) // - don't probe (the probe would only confirm the revoked account can't use // it, or worse mislead). Only fall through for servers that don't advertise // the capability at all. if (this.hasCapability("urn:ietf:params:jmap:filenode")) return false; 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"); } // Required for shareWith/myRights on FileNode and for cross-account // (shared-with-me) FileNode/get, mirroring calendarUsing(). if (this.supportsPrincipals()) { using.push("urn:ietf:params:jmap:principals:owner"); } return using; } private static FILE_NODE_PROPERTIES = [ "id", "parentId", "name", "type", "blobId", "size", "created", "modified", // Stalwart omits shareWith/myRights from FileNode/get unless requested // explicitly, so the share dialog and indicators can't see existing // shares without naming them here (same as CALENDAR_PROPERTIES). "shareWith", "myRights", ]; // Accounts (primary + shared/group) that can hold FileNodes. Mirrors // getCalendarCapableAccountIds(): includes any non-primary account that // advertises the filenode capability or is a non-personal (shared/group) // account, since Stalwart doesn't always advertise capabilities on those. private getFilesCapableAccountIds(): string[] { const primaryId = this.getFilesAccountId(); const accountIds: string[] = []; for (const [id, account] of Object.entries(this.accounts)) { if (id === primaryId) continue; if (account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal) { accountIds.push(id); } } return [primaryId, ...accountIds]; } async getFileNodes(ids: string[] | null, properties?: string[]): Promise { const accountId = this.getFilesAccountId(); const args: Record = { 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 { const accountId = this.getFilesAccountId(); const args: Record = { 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/query omits folder nodes in Stalwart (see listAllFileNodes), so // we enumerate the whole account and filter children client-side. const all = await this.listAllFileNodes(); return all.filter(n => (n.parentId ?? null) === parentId); } /** * Fetch every FileNode in the account, files AND folders, to build the folder * hierarchy client-side from parentId links. * * IMPORTANT: this uses `FileNode/get` with `ids: null` (return-all), NOT * `FileNode/query`. Stalwart's FileNode/query only returns leaf files - it * omits container (folder) nodes entirely - so a query-based listing made * every folder invisible (the whole account looked like a single root file). */ async listAllFileNodes(): Promise { const accountId = this.getFilesAccountId(); const response = await this.request( [["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]], this.fileUsing(), ); const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0")); if (!getResult || getResult[0] === "error") { console.error('[Files] FileNode/get error:', getResult?.[1]); throw new Error(getResult?.[1]?.description || "FileNode list failed"); } return (getResult[1].list || []) as FileNode[]; } /** * Fetch every FileNode the logged-in user can see across all connected and * shared accounts. Nodes owned by another principal (shared with the user) * are tagged with `isShared: true` and the owning `accountId`/`accountName`, * and their ids are namespaced `accountId:nodeId` so they don't collide with * the primary account's ids. Mirrors getAllCalendars(). */ async listAllFileNodesAcrossAccounts(): Promise { const primaryId = this.getFilesAccountId(); const accountIds = this.getFilesCapableAccountIds(); const all: FileNode[] = []; for (const accountId of accountIds) { const isPrimary = accountId === primaryId; const account = this.accounts[accountId]; try { const response = await this.request( [["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]], this.fileUsing(), ); const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get"); if (!getResult || getResult[0] === "error") continue; const nodes = (getResult[1].list || []) as FileNode[]; for (const node of nodes) { all.push({ ...node, id: isPrimary ? node.id : `${accountId}:${node.id}`, parentId: node.parentId == null ? null : (isPrimary ? node.parentId : `${accountId}:${node.parentId}`), accountId, accountName: account?.name || (isPrimary ? this.username : accountId), isShared: !isPrimary, }); } } catch (error) { console.error(`[Files] Failed to fetch FileNodes for account ${accountId}:`, error); } } return all; } /** * Add, update, or remove a principal's rights on a FileNode (file or folder). * Pass `rights: null` to revoke access. Mirrors setCalendarShare / * setAddressBookShare; Stalwart applies it via a `shareWith/{principalId}` * patch on FileNode/set. */ async setFileNodeShare( fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string, ): Promise { const accountId = targetAccountId || this.getFilesAccountId(); const response = await this.request([ ["FileNode/set", { accountId, update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } }, }, "0"], ], this.fileUsing()); const result = response.methodResponses?.[0]?.[1]; if (result?.notUpdated?.[fileNodeId]) { const err = result.notUpdated[fileNodeId]; throw new Error(err.description || "Failed to update file share"); } if (!result?.updated || !(fileNodeId in result.updated)) { throw new Error("Server did not confirm the share update"); } } async createFileDirectory(name: string, parentId: string | null): Promise { const accountId = this.getFilesAccountId(); // A FileNode is a folder (container) only when it has no content - i.e. no // blobId, type or size, so the server stores it with `file == null`. Sending // any of those - as older builds did (type "d" + an empty blob) - makes it a // 0-byte FILE that nothing can ever be parented under, which is what caused // "Parent ID does not exist or is not a folder" during the #379 migration. const dirProps: Record = { name }; 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 { const accountId = this.getFilesAccountId(); //fall back for long MIME types const safeType = type.length > 30 ? 'application/octet-stream' : type; const fileProps: Record = { name, type: safeType, 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>): Promise { 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"); } } /** * Update many FileNodes in a single FileNode/set call. Returns the ids that * were updated and a map of id -> error for any the server rejected. Never * throws for per-node failures (only for a whole-method error). */ async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> { const entries = Object.entries(updates); if (entries.length === 0) return { updated: [], notUpdated: {} }; const accountId = this.getFilesAccountId(); const updated: string[] = []; const notUpdated: Record = {}; for (const batch of batched(entries, this.getMaxObjectsInSet())) { const response = await this.request( [["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]], this.fileUsing(), ); const result = response.methodResponses?.[0]; if (!result || result[0] === "error") { throw new Error(result?.[1]?.description || "FileNode/set update failed"); } const updatedMap: Record = result[1].updated || {}; const notUpdatedMap: Record = result[1].notUpdated || {}; for (const id of Object.keys(notUpdatedMap)) { notUpdated[id] = notUpdatedMap[id]?.description || 'not updated'; } // Servers may omit the `updated` map; treat anything not rejected as updated. updated.push(...(Object.keys(updatedMap).length > 0 ? Object.keys(updatedMap) : batch.map(([id]) => id).filter(id => !(id in notUpdated)))); } return { updated, notUpdated }; } async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { const accountId = this.getFilesAccountId(); const destroyed: string[] = []; for (const batch of batched(ids, this.getMaxObjectsInSet())) { const response = await this.request( [["FileNode/set", { accountId, destroy: batch, onDestroyRemoveChildren: true, }, "fns0"]], this.fileUsing(), ); const result = response.methodResponses?.[0]; if (!result || result[0] === "error") { throw new Error(result?.[1]?.description || "FileNode/set destroy failed"); } const notDestroyedMap: Record = result[1].notDestroyed || {}; const notDestroyedIds = Object.keys(notDestroyedMap); if (notDestroyedIds.length > 0) { const firstError = notDestroyedMap[notDestroyedIds[0]]; throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`); } destroyed.push(...(result[1].destroyed || [])); } return { destroyed, notDestroyed: [] }; } async copyFileNode(id: string, newName: string, parentId: string | null): Promise { // 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 = { 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, accountId?: string): Promise { const blob = await this.fetchBlob(blobId, name, type, accountId); 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 secondaryPollInterval: NodeJS.Timeout | null = null; private pollingStates: { [key: string]: string } = {}; private sseAbortController: AbortController | null = null; private sseReconnectTimeout: NodeJS.Timeout | null = null; private ssePingTimer: NodeJS.Timeout | null = null; private lastSSEActivity: number = 0; private visibilityHandler: (() => void) | null = null; private onlineHandler: (() => void) | null = null; private offlineHandler: (() => void) | null = null; // JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server // advertises it (getWebSocketUrl()), since it's the transport the desktop // shell's main process eventually wants for background/no-window // notifications (see electron/preload.ts's showNotification bridge). // Falls back to the existing SSE/polling chain below when unsupported OR // when the handshake itself keeps failing (see wsPermanentlyDisabled). // // KNOWN LIMITATION, confirmed empirically against the sandbox server this // was built against (stalwart.sandbox.vnc.de): its /jmap/ws endpoint // requires the same HTTP Basic/Bearer Authorization header as every other // JMAP endpoint on the WebSocket UPGRADE request itself (curling it with // no Authorization header returns a plain 401 before any WS frame is // possible). The browser WebSocket constructor has no way to attach // custom headers to that handshake (a WHATWG spec restriction, not an // Electron/browser quirk - credentials in the URL are actively rejected // too), so from this renderer-side client there is no way to satisfy that // auth requirement. Against a server with this exact auth model, every // connection attempt below will fail at the handshake and the circuit // breaker (wsPermanentlyDisabled) will fall back to SSE after a few quick // retries - which is not a bug in this code, it is what actually happens // on the wire. It's still implemented for real (not stubbed) because (a) // it's fully spec-correct and will light up automatically against any // server whose WS endpoint doesn't have this requirement - e.g. one // sitting behind a proxy that authenticates via cookies instead - with no // further changes, and (b) the alternative (opening it from Electron's // main process via a header-capable client like the `ws` package) would // mean piping raw credentials from the renderer to the main process over // IPC, which is a materially bigger security-sensitive change than what // was scoped here. private ws: WebSocket | null = null; private wsReconnectTimeout: NodeJS.Timeout | null = null; private wsReconnectAttempts: number = 0; private wsConsecutiveFailures: number = 0; private wsPermanentlyDisabled: boolean = false; private _statesPromise: Promise | null = null; private wsHeartbeatTimer: NodeJS.Timeout | null = null; private lastWSActivity: number = 0; private static readonly STATE_TYPE_MAP: Record = { 'Mailbox/get': 'Mailbox', 'Email/get': 'Email', 'Calendar/get': 'Calendar', 'CalendarEvent/get': 'CalendarEvent', 'SieveScript/get': 'SieveScript', 'ContactCard/get': 'ContactCard', 'FileNode/get': 'FileNode', }; private static readonly POLLING_INTERVAL = 3_000; // Shared/secondary accounts get no SSE push (Stalwart pushes the primary // account only), so poll them on a slow cadence alongside SSE to keep their // folder + unified/All-Mail counters from going stale between focus events. private static readonly SECONDARY_POLL_INTERVAL = 20_000; private static readonly SSE_RECONNECT_DELAY = 3_000; private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval // Exponential backoff with full jitter (0..cap), doubling from a 200ms // base and capping at 5s. // // Deliberately much tighter than a "normal" reconnect ladder (something // like 1s/30s would be the textbook default for a flaky network) - and // tuned from a real, measured failure mode, not guessed: the auth // limitation described above fails FAST and DETERMINISTICALLY (the // handshake is rejected before the socket ever opens, in well under a // second, every single time), not slowly. Verified empirically (see // integration/tests/11-electron-notification.spec.ts's development) that // the original 1s-base/30s-cap/5-attempt ladder let the circuit breaker // take up to ~31s to trip, during which there is NO live push at all // (WS hasn't succeeded and hasn't given up yet, so SSE never even starts // connecting) - a real mail delivery landing in that window was missed // entirely, since SSE only streams future changes and does no catch-up // fetch on connect. This tighter ladder closes that gap to a fraction of // a second for the fast-fail case while remaining exactly as protective // for a genuinely slow/flaky network: a hanging attempt is still bounded // by the browser's own WebSocket connect timeout regardless of these // constants, which govern only the GAP between attempts, not how long a // single attempt is allowed to hang. private static readonly WS_RECONNECT_BASE_DELAY = 200; private static readonly WS_RECONNECT_MAX_DELAY = 5_000; // App-level heartbeat: a WebSocket can sit in "open" readyState for a long // time after the underlying network path is actually gone (sleep, network // switch, a NAT/proxy that silently drops idle connections) - TCP alone // won't always surface that promptly. Send a lightweight JMAP request // every 30s and force-reconnect if nothing (heartbeat response OR a real // push) has arrived within 3x that window, mirroring the SSE ping monitor // above. private static readonly WS_HEARTBEAT_INTERVAL = 30_000; private static readonly WS_ACTIVITY_TIMEOUT = 90_000; // Give up on WS for this client instance after this many CONSECUTIVE // attempts that never reach "open" (a connection that opened fine and // later dropped does not count - see connectWebSocket's openedSuccessfully // tracking). Bounds the cost of the auth limitation described above to a // handful of quick handshake attempts (with the tightened backoff above, // well under a second in the common fast-fail case) instead of retrying a // request that can never succeed, forever, for the lifetime of the session. private static readonly WS_MAX_CONSECUTIVE_FAILURES = 3; /** getWebSocketUrl(), gated by the circuit breaker above. */ private effectiveWebSocketUrl(): string | null { return this.wsPermanentlyDisabled ? null : this.getWebSocketUrl(); } setupPushNotifications(): boolean { const wsUrl = this.effectiveWebSocketUrl(); if (wsUrl) { this.wsReconnectAttempts = 0; this.connectWebSocket(wsUrl); // Prime the polling baseline (pollingStates) in parallel with the WS // attempt, not just for shared/secondary accounts below - if WS ends // up failing and falling back (fallbackFromWebSocket()), this is what // lets that fallback reconcile anything that changed to the PRIMARY // account while WS was still churning through retries. Without an // early baseline, a change in that window would be silently missed // entirely: SSE only streams changes from the moment it connects // onward (no catch-up on connect), so the one thing that CAN catch up // is a diff against a state snapshot taken before the gap started. this._statesPromise = this.fetchCurrentStates(); // Not confirmed either way whether this server's WebSocket push fans // out to shared/secondary accounts or, like Stalwart's SSE, covers the // primary account only - keep the same secondary poll running under // WS that SSE already needed, rather than assume broader coverage and // risk shared-account counters going stale. this.startSecondaryAccountPoll(); } else { const eventSourceUrl = this.getEventSourceUrl(); if (eventSourceUrl) { this.connectSSE(eventSourceUrl); // SSE covers the primary account only; keep shared accounts fresh too. this.startSecondaryAccountPoll(); } else { // The fallback poll already covers every session account. this.startPollingFallback(); } } this.setupBrowserEventListeners(); return true; } /** * Opens the RFC 8887 JMAP-over-WebSocket connection and subscribes to * push for every data type (`WebSocketPushEnable` with dataTypes: null). * Reconnect on close/error is handled by scheduleWSReconnect() below with * exponential backoff - this method only ever represents a single * connection attempt. */ private connectWebSocket(wsUrl: string): void { if (this.isRateLimited()) { this.scheduleWSReconnect(); return; } let socket: WebSocket; try { socket = new WebSocket(wsUrl, "jmap"); } catch { // New URL()-level failures (malformed URL) - retry later in case a // session refresh fixes it; getWebSocketUrl() re-reads capabilities // fresh on every attempt. this.scheduleWSReconnect(); return; } this.ws = socket; const isCurrent = () => this.ws === socket; // Tracks whether THIS specific attempt ever reached "open" - a socket // that opened fine and dropped later (real network blip on an // established connection) must not count toward the circuit breaker the // same way a handshake that never completes does (see // wsPermanentlyDisabled's declaration above for why the latter needs // one at all). let openedSuccessfully = false; socket.addEventListener("open", () => { if (!isCurrent()) return; openedSuccessfully = true; // A real connection succeeded - both counters reset: the backoff // ladder no longer applies to whatever eventually causes the NEXT // disconnect, and the "give up on WS entirely" counter only tracks // CONSECUTIVE handshake failures. this.wsReconnectAttempts = 0; this.wsConsecutiveFailures = 0; this.lastWSActivity = Date.now(); this.startWSHeartbeat(socket); try { socket.send(JSON.stringify({ "@type": "WebSocketPushEnable", dataTypes: null })); } catch { // send() can throw if the socket already closed between "open" // firing and this line running - the "close" handler below will // schedule a reconnect regardless. } }); socket.addEventListener("message", (event) => { if (!isCurrent()) return; this.lastWSActivity = Date.now(); this.processWebSocketMessage(typeof event.data === "string" ? event.data : ""); }); socket.addEventListener("close", () => { if (!isCurrent()) return; this.stopWSHeartbeat(); this.ws = null; if (this.intentionallyDisconnected) return; if (!openedSuccessfully) { this.wsConsecutiveFailures += 1; if (this.wsConsecutiveFailures >= JMAPClient.WS_MAX_CONSECUTIVE_FAILURES) { // The handshake itself is what's failing, repeatedly - most // commonly (confirmed against this client's own reference // server) because the WS endpoint requires an Authorization // header the browser WebSocket API cannot attach. Retrying that // forever would just hammer the server every ~30s with a request // that can never succeed from here. Give up on WS for the rest of // this client instance's life and stay on SSE/polling, which // don't have this limitation. this.wsPermanentlyDisabled = true; console.warn( '[JMAP] WebSocket push failed to establish after repeated attempts; falling back to SSE/polling for this session.', ); this.fallbackFromWebSocket(); return; } } this.scheduleWSReconnect(); }); // WebSocket always fires "close" right after "error" - the reconnect // logic lives entirely in the "close" handler above so there is exactly // one path that schedules a retry, not two racing each other. } /** Whatever push transport SSE would have used, now that WS has given up. */ private fallbackFromWebSocket(): void { void this.reconcileAfterWebSocketFallback(); } /** * Diffs against the baseline setupPushNotifications() primed via * fetchCurrentStates() when the WS attempt began - BEFORE either branch * below gets a chance to erase that opportunity (startPollingFallback() * unconditionally overwrites the same baseline via its own * fetchCurrentStates() call; connectSSE() only ever streams changes from * the moment it connects onward, no catch-up). This is what catches a * real mail delivery (or any other tracked change) that happened to the * primary account while WS was still churning through retries, which * neither of those two paths would otherwise ever notice - confirmed as a * real, not theoretical, gap during this feature's own development (see * the WS_RECONNECT_BASE_DELAY comment above). * * Not airtight: if the early fetchCurrentStates() from * setupPushNotifications() hasn't itself completed yet by the time this * runs, there's nothing to diff against and this call just establishes * the baseline instead of detecting drift. In practice that race needs a * pathologically slow state-fetch racing an unusually fast WS failure, * and the tightened backoff above (worst case ~1.75s to exhaust 3 * attempts) gives that fetch a lot more room to finish first than the * original 31s-worst-case ladder did. */ private async reconcileAfterWebSocketFallback(): Promise { await this._statesPromise; await this.checkForStateChanges(); const eventSourceUrl = this.getEventSourceUrl(); if (eventSourceUrl) { this.connectSSE(eventSourceUrl); this.startSecondaryAccountPoll(); } else { this.startPollingFallback(); } } /** * Parses one WebSocket text frame. Per RFC 8887 the server can send * Response, StateChange, or PushState frames; only StateChange is * consumed today (method calls aren't yet routed over this socket - * request()/authenticatedFetch() still uses plain HTTP), so anything else * is silently ignored rather than treated as an error. */ private processWebSocketMessage(raw: string): void { if (!raw) return; let message: { "@type"?: string; changed?: StateChange["changed"] } | null = null; try { message = JSON.parse(raw); } catch { return; // malformed frame - ignore, matches processSSEEvent's handling } if (message?.["@type"] === "StateChange" && message.changed) { this.stateChangeCallback?.({ "@type": "StateChange", changed: message.changed }); } } private scheduleWSReconnect(): void { if (this.intentionallyDisconnected) return; if (this.wsReconnectTimeout) return; // already scheduled - don't stack retries const wsUrl = this.effectiveWebSocketUrl(); if (!wsUrl) { // Either the server capability disappeared (e.g. a session refresh // dropped WebSocket support) or the circuit breaker already tripped - // fall back to whatever push transport is still available instead of // retrying a URL that's gone or a handshake that won't succeed. this.fallbackFromWebSocket(); return; } const attempt = this.wsReconnectAttempts; this.wsReconnectAttempts += 1; const exponential = JMAPClient.WS_RECONNECT_BASE_DELAY * Math.pow(2, attempt); const cap = Math.min(exponential, JMAPClient.WS_RECONNECT_MAX_DELAY); // Full jitter (uniform 0..cap) rather than a fixed exponential delay - // spreads reconnect attempts out after a shared network blip (proxy // restart, wifi handoff affecting every open tab/window at once) // instead of having them all retry in lockstep. const delay = Math.random() * cap; this.wsReconnectTimeout = setTimeout(() => { this.wsReconnectTimeout = null; if (this.isRateLimited()) { this.scheduleWSReconnect(); return; } this.connectWebSocket(wsUrl); }, delay); } private startWSHeartbeat(socket: WebSocket): void { this.stopWSHeartbeat(); this.wsHeartbeatTimer = setInterval(() => { if (this.ws !== socket) return; if (Date.now() - this.lastWSActivity > JMAPClient.WS_ACTIVITY_TIMEOUT) { // Silently dead connection (sleep/network switch/idle proxy) - the // socket can still report readyState OPEN long after the underlying // path is gone. Force-close; the "close" handler schedules the // reconnect via the normal backoff path. this.stopWSHeartbeat(); try { socket.close(); } catch { // Already closing/closed - the "close" handler (if it hasn't // already run) will still fire and take care of reconnecting. } return; } try { socket.send(JSON.stringify({ "@type": "Request", requestId: `ws-heartbeat-${Date.now()}`, using: ["urn:ietf:params:jmap:core"], methodCalls: [["Core/echo", {}, "0"]], })); } catch { // send() failing means the socket is already dead - the activity // timeout above will catch it on the next tick if "close" doesn't // fire first. } }, JMAPClient.WS_HEARTBEAT_INTERVAL); } private stopWSHeartbeat(): void { if (this.wsHeartbeatTimer) { clearInterval(this.wsHeartbeatTimer); this.wsHeartbeatTimer = null; } } /** * Slow poll of the session's shared/secondary accounts, run in parallel with * SSE (which never reports them). Skipped when there are no shared accounts, * and paused while the tab is hidden (visibilitychange forces a check on * return). Reuses checkForStateChanges, which already reports per-account. */ private startSecondaryAccountPoll(): void { if (this.secondaryPollInterval) return; const hasSecondary = this.pollAccountIds().some((id) => id !== this.accountId); if (!hasSecondary) return; // Prime the per-account baseline so the first tick doesn't false-fire. void this.fetchCurrentStates(); this.secondaryPollInterval = setInterval(() => { if (typeof document !== 'undefined' && document.hidden) return; void this.checkForStateChanges(); }, JMAPClient.SECONDARY_POLL_INTERVAL); } private connectSSE(templateUrl: string): void { if (this.isRateLimited()) { this.scheduleSSEReconnect(); return; } const url = templateUrl .replace('{types}', '*') .replace('{closeafter}', 'no') .replace('{ping}', '30'); // Each attempt tracks its own controller. When closePushNotifications // aborts a connect that is still in flight (every account switch tears // down and re-creates push for all connected clients), the rejection // lands in the catch below AFTER the next attempt has already been set // up - treating it as a network failure there would spawn an // unsupervised polling interval and, via fallbackToPolling nulling // sseAbortController, orphan the replacement connection. const controller = new AbortController(); this.sseAbortController = controller; this.authenticatedFetch(url, { headers: { 'Accept': 'text/event-stream' }, signal: controller.signal, }).then(response => { if (controller.signal.aborted) return; if (!response.ok || !response.body) { this.fallbackToPolling(); return; } this.readSSEStream(response.body, controller); }).catch((error) => { // Intentional close, not a failure - no polling fallback. if (controller.signal.aborted) return; if (error instanceof RateLimitError) { this.sseAbortController = null; this.scheduleSSEReconnect(); return; } this.fallbackToPolling(); }); } private async readSSEStream(body: ReadableStream, controller: AbortController): Promise { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = ''; this.lastSSEActivity = Date.now(); this.startSSEPingMonitor(); try { while (true) { const { done, value } = await reader.read(); if (done) break; this.lastSSEActivity = Date.now(); buffer += decoder.decode(value, { stream: true }); const parts = buffer.split('\n\n'); buffer = parts.pop() || ''; for (const part of parts) { this.processSSEEvent(part); } } } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return; } this.stopSSEPingMonitor(); // Stream ended - reconnect only if this stream is still the current one // and was not intentionally closed. A superseded stream must not spawn a // second connection next to its replacement. if (this.sseAbortController === controller && !controller.signal.aborted) { this.scheduleSSEReconnect(); } } private processSSEEvent(raw: string): void { let eventType = 'message'; let dataLines: string[] = []; for (const line of raw.split('\n')) { if (line.startsWith('event:')) { eventType = line.slice(6).trim(); } else if (line.startsWith('data:')) { dataLines.push(line.slice(5).trim()); } } if (eventType === 'state' && dataLines.length > 0) { try { const change = JSON.parse(dataLines.join('\n')) as StateChange; this.stateChangeCallback?.(change); } catch { // Malformed SSE data - ignore } } } private scheduleSSEReconnect(): void { if (this.intentionallyDisconnected) return; const eventSourceUrl = this.getEventSourceUrl(); if (!eventSourceUrl) { this.fallbackToPolling(); return; } const delay = this.isRateLimited() ? Math.max(this.rateLimitedUntil - Date.now(), JMAPClient.SSE_RECONNECT_DELAY) : JMAPClient.SSE_RECONNECT_DELAY; this.sseReconnectTimeout = setTimeout(() => { if (this.isRateLimited()) { this.scheduleSSEReconnect(); return; } this.connectSSE(eventSourceUrl); }, delay); } private fallbackToPolling(): void { this.sseAbortController = null; if (!this.pollingInterval) { this.startPollingFallback(); } } private startPollingFallback(): void { if (this.intentionallyDisconnected) return; if (this.isRateLimited()) { return; } this.fetchCurrentStates(); this.pollingInterval = setInterval(() => { this.checkForStateChanges(); }, JMAPClient.POLLING_INTERVAL); } /** * Accounts whose Mailbox/Email state the poll should track. Stalwart's SSE * only pushes StateChange for the primary account, never for delegated/shared * (secondary) accounts, so their folder counters — and the unified/All-Mail * badges that aggregate them — would otherwise never refresh from a background * change. Polling every session account (primary + shared) closes that gap on * the visibility/interval reconcile path. Mailbox/Email get callIds are tagged * with the accountId (`mbx:` / `eml:`) so each account is compared * independently. (#shared-counter-push) */ private pollAccountIds(): string[] { const ids = Object.keys(this.accounts || {}); return ids.length > 0 ? ids : [this.accountId]; } private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } { const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; const methodCalls: JMAPMethodCall[] = []; for (const acctId of this.pollAccountIds()) { methodCalls.push( ['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`], ['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`], ); } 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'], ); } // Contacts and files get no push at all today (mail-index's event-driven // reindex depends on this poll to notice them when SSE/WS isn't // available) - mirrors the Calendar branch above, same accountId caveat. if (this.supportsContacts()) { using.push('urn:ietf:params:jmap:contacts'); methodCalls.push( ['ContactCard/get', { accountId: this.getContactsAccountId(), ids: [], properties: ['id'] }, 'f'], ); } if (this.hasCapability('urn:ietf:params:jmap:filenode')) { using.push('urn:ietf:params:jmap:filenode'); methodCalls.push( ['FileNode/get', { accountId: this.getFilesAccountId(), ids: [], properties: ['id'] }, 'g'], ); } return { using, methodCalls }; } /** Map a polled method response back to its (accountId, stateKey). */ private resolvePolledState(method: string, callId: unknown): { accountId: string; stateKey: string } | null { if (typeof callId === 'string') { if (callId.startsWith('mbx:')) return { accountId: callId.slice(4), stateKey: 'Mailbox' }; if (callId.startsWith('eml:')) return { accountId: callId.slice(4), stateKey: 'Email' }; } const stateKey = JMAPClient.STATE_TYPE_MAP[method]; return stateKey ? { accountId: this.accountId, stateKey } : null; } private async fetchCurrentStates(): Promise { if (this.isRateLimited()) { return; } 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, callId] of data.methodResponses) { const resolved = this.resolvePolledState(method, callId); if (resolved && result?.state) { this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state; } } } } catch { // Silently fail - polling will retry } } private async checkForStateChanges(): Promise { if (this.isRateLimited()) { return; } 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(); // Build a per-account changed map so a background change in a shared // (secondary) account is reported under its own accountId — which // handleStateChange treats as "some mailbox changed" and refetches the // full (own + delegated) mailbox list from. const changedByAccount: Record> = {}; for (const [method, result, callId] of data.methodResponses) { const resolved = this.resolvePolledState(method, callId); if (!resolved || !result?.state) continue; const key = `${resolved.accountId}:${resolved.stateKey}`; if (this.pollingStates[key] && this.pollingStates[key] !== result.state) { (changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state; } this.pollingStates[key] = result.state; } if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) { this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount }); } } } catch { // Silently fail - polling will retry } } closePushNotifications(): void { if (this.pollingInterval) { clearInterval(this.pollingInterval); this.pollingInterval = null; } if (this.secondaryPollInterval) { clearInterval(this.secondaryPollInterval); this.secondaryPollInterval = null; } if (this.sseAbortController) { this.sseAbortController.abort(); this.sseAbortController = null; } if (this.sseReconnectTimeout) { clearTimeout(this.sseReconnectTimeout); this.sseReconnectTimeout = null; } if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.stopSSEPingMonitor(); if (this.wsReconnectTimeout) { clearTimeout(this.wsReconnectTimeout); this.wsReconnectTimeout = null; } this.stopWSHeartbeat(); if (this.ws) { // Null out this.ws BEFORE close() so the "close" event handler's // isCurrent() check (this.ws === socket) sees a mismatch once the // event fires and skips scheduling a reconnect - this is an // intentional teardown, not a dropped connection. const socket = this.ws; this.ws = null; try { socket.close(); } catch { // Already closing/closed. } } this.wsReconnectAttempts = 0; this.wsConsecutiveFailures = 0; this.wsPermanentlyDisabled = false; this.cleanupBrowserEventListeners(); this.stateChangeCallback = null; this.pollingStates = {}; } private startSSEPingMonitor(): void { this.stopSSEPingMonitor(); this.ssePingTimer = setInterval(() => { if (Date.now() - this.lastSSEActivity > JMAPClient.SSE_PING_TIMEOUT) { // SSE connection is stale - abort and reconnect this.stopSSEPingMonitor(); if (this.sseAbortController) { this.sseAbortController.abort(); this.sseAbortController = null; } this.scheduleSSEReconnect(); } }, 30_000); } private stopSSEPingMonitor(): void { if (this.ssePingTimer) { clearInterval(this.ssePingTimer); this.ssePingTimer = null; } } private setupBrowserEventListeners(): void { if (typeof document !== 'undefined') { this.visibilityHandler = () => { if (!document.hidden) { // Tab became visible - immediately check for state changes this.checkForStateChanges(); } }; document.addEventListener('visibilitychange', this.visibilityHandler); } if (typeof window !== 'undefined') { this.offlineHandler = () => { this.closePushNotifications(); }; window.addEventListener('offline', this.offlineHandler); this.onlineHandler = () => { // Network reconnected - reconnect WS/SSE or force a poll. Don't // make the user wait through whatever backoff delay was already in // flight from repeated failures while offline - the network is // confirmed back, so retry immediately. const wsUrl = this.effectiveWebSocketUrl(); if (wsUrl) { if (!this.ws) { if (this.wsReconnectTimeout) { clearTimeout(this.wsReconnectTimeout); this.wsReconnectTimeout = null; } this.wsReconnectAttempts = 0; this.connectWebSocket(wsUrl); } return; } const eventSourceUrl = this.getEventSourceUrl(); if (eventSourceUrl && !this.sseAbortController) { this.connectSSE(eventSourceUrl); } else { this.checkForStateChanges(); } }; window.addEventListener('online', this.onlineHandler); } } private cleanupBrowserEventListeners(): void { if (this.visibilityHandler && typeof document !== 'undefined') { document.removeEventListener('visibilitychange', this.visibilityHandler); this.visibilityHandler = null; } if (this.offlineHandler && typeof window !== 'undefined') { window.removeEventListener('offline', this.offlineHandler); this.offlineHandler = null; } if (this.onlineHandler && typeof window !== 'undefined') { window.removeEventListener('online', this.onlineHandler); this.onlineHandler = null; } } onConnectionChange(callback: (connected: boolean) => void): void { this.connectionChangeCallback = callback; } onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void { this.rateLimitCallback = callback; } isRateLimited(): boolean { return Date.now() < this.rateLimitedUntil; } getRateLimitRemainingMs(): number { return Math.max(0, this.rateLimitedUntil - Date.now()); } private setRateLimited(retryAfterMs: number): void { this.rateLimitedUntil = Date.now() + retryAfterMs; if (this.rateLimitTimeout) { clearTimeout(this.rateLimitTimeout); this.rateLimitTimeout = null; } this.rateLimitCallback?.(true, retryAfterMs); // Pause live updates until the server's rate-limit window expires. const stateChangeCallback = this.stateChangeCallback; this.closePushNotifications(); this.stateChangeCallback = stateChangeCallback; // Schedule clearing the rate-limit flag and notifying listeners. this.rateLimitTimeout = setTimeout(() => { this.rateLimitTimeout = null; if (!this.isRateLimited()) { this.rateLimitCallback?.(false, 0); if (this.session && this.stateChangeCallback) { this.setupPushNotifications(); } } }, retryAfterMs); } private notifyRateLimitBlocked(retryAfterMs: number): void { if (typeof window === 'undefined') { return; } const now = Date.now(); if ((now - this.lastRateLimitNoticeAt) < JMAPClient.RATE_LIMIT_TOAST_THROTTLE_MS) { return; } this.lastRateLimitNoticeAt = now; window.dispatchEvent(new CustomEvent('bulwark:rate-limit-blocked', { detail: { retryAfterMs }, })); } private static parseRetryAfter(response: Response): number { const header = response.headers.get('Retry-After'); if (!header) return 60_000; // default 60s if no header const seconds = Number(header); if (!Number.isNaN(seconds) && seconds > 0) { return Math.min(seconds * 1000, 300_000); // cap at 5 minutes } // Try HTTP-date format const date = Date.parse(header); if (!Number.isNaN(date)) { const ms = date - Date.now(); return ms > 0 ? Math.min(ms, 300_000) : 60_000; } return 60_000; } 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, accountId?: string): Promise { const url = this.getBlobDownloadUrl(blobId, name, type, accountId); 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. Pass `accountId` to * target a delegated account the caller has rights on (e.g. importing into * a shared mailbox owned by another user). When omitted, falls back to the * client's own primary account. */ async copyEmailAcrossAccounts( emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string, ): Promise { // Email/copy drops keywords unless the create sets them, so carry the // source's over — otherwise the moved message shows up as unread. const srcResp = await this.request([ ["Email/get", { accountId: fromAccountId, ids: [emailId], properties: ["keywords"] }, "0"], ]); const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {}; // onSuccessDestroyOriginal is the spec-correct way to remove the source, but // Stalwart currently destroys the copy's create-id instead of the source id, // so the original is left behind — a duplicate on every cross-account move. // Reported upstream (support.stalw.art #1150); this self-heals once fixed. const response = await this.request([ ["Email/copy", { fromAccountId, accountId: toAccountId, create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } }, onSuccessDestroyOriginal: true, }, "0"], ]); const res = response.methodResponses?.[0]?.[1]; const err = res?.notCreated?.c; if (err) { throw new Error(err.description || err.type || "Failed to copy email across accounts"); } const id = res?.created?.c?.id; if (!id) { throw new Error("Email/copy succeeded but no ID returned"); } return id; } async importRawEmail( blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string, ): Promise { const targetAccountId = accountId || this.accountId; // First upload the blob. Blob uploads are scoped to an account too — // when importing into a delegated account, upload there so the resulting // blobId is visible to Email/import on that account. const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const { blobId } = await this.uploadBlob(file, targetAccountId); // Then import via Email/import const response = await this.request([ ['Email/import', { accountId: targetAccountId, 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 { const response = await this.request([ ['EmailSubmission/set', { accountId: this.getSubmissionAccountId(), 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 PGP/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, delayedUntil?: string, envelopeRecipients?: string[], ): Promise { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; // Upload the raw message const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const { blobId } = await this.uploadBlob(file); // Import into Drafts first, then move to Sent after submission succeeds. // This avoids encrypt-on-append affecting the SMTP send. See #188. const importMailboxId = draftMailboxId || sentMailboxId; const identities = await this.getIdentities(); const identity = identities.find(item => item.id === identityId); const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); const methodCalls: [string, Record, string][] = [ ['Email/import', { accountId: this.accountId, emails: { 'raw-import': { blobId, mailboxIds: { [importMailboxId]: true }, keywords: draftMailboxId ? { '$seen': true, '$draft': true } : { '$seen': true }, }, }, }, '0'], ['EmailSubmission/set', { accountId: this.getSubmissionAccountId(), create: { 'raw-submit': { emailId: '#raw-import', identityId, ...(envelope ? { envelope } : {}), }, }, ...(draftMailboxId ? { onSuccessUpdateEmail: { '#raw-submit': { ...mailboxIdsReplacement(sentMailboxId), 'keywords/$draft': null, }, }, } : {}), }, '1'], ]; const response = await this.request(methodCalls); let emailId: string | undefined; let emailSubmissionId: string | undefined; let serverSendAt: string | undefined; // 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 }; if (r.notCreated) { const firstErr = Object.values(r.notCreated)[0]; throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email'); } if (methodName === 'Email/import') { emailId = (result as { created?: Record }).created?.['raw-import']?.id; } if (methodName === 'EmailSubmission/set') { const created = (result as { created?: Record }).created?.['raw-submit']; emailSubmissionId = created?.id; serverSendAt = created?.sendAt; } } if (delayedUntil && emailSubmissionId && !serverSendAt) { serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); } return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, isSmime: true } : { scheduled: false, emailId, emailSubmissionId, isSmime: true }; } /** * Submit a raw email blob to the network via JMAP EmailSubmission without auto-archiving to Sent. */ async submitRawEmail( blob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[], ): Promise { const mailboxes = await this.getMailboxes(); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); if (!draftsMailbox) { throw new Error('Drafts mailbox not found'); } const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const { blobId } = await this.uploadBlob(file); const identities = await this.getIdentities(); const identity = identities.find(item => item.id === identityId); const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); //Temporarily import the raw email into Drafts to satisfy JMAP's requirement that an EmailSubmission references an existing Email. // The Email will be destroyed after submission. const methodCalls: [string, Record, string][] = [ ['Email/import', { accountId: this.accountId, emails: { 'temp-submit': { blobId, mailboxIds: { [draftsMailbox.id]: true }, keywords: { '$draft': true }, }, }, }, '0'], ['EmailSubmission/set', { accountId: this.getSubmissionAccountId(), create: { 'raw-submit': { emailId: '#temp-submit', identityId, ...(envelope ? { envelope } : {}), }, }, //destroy the temporary email after submission to avoid leaving a draft behind. onSuccessDestroyEmail: ['#raw-submit'], }, '1'], ]; const response = await this.request(methodCalls); let emailSubmissionId: string | undefined; let serverSendAt: string | undefined; 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 }; if (r.notCreated) { const firstErr = Object.values(r.notCreated)[0]; throw new Error(firstErr?.description || firstErr?.type || 'Failed to submit raw email'); } if (methodName === 'EmailSubmission/set') { const created = (result as { created?: Record }).created?.['raw-submit']; emailSubmissionId = created?.id; serverSendAt = created?.sendAt; } } if (delayedUntil && emailSubmissionId && !serverSendAt) { serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId); } return delayedUntil ? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, isSmime: true } : { scheduled: false, emailSubmissionId, isSmime: true }; } async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> { if (!this.hasDelayedSend()) { return { emails: [], hasMore: false, total: 0, nextPosition: position }; } const now = Date.now(); const pageSize = Math.max(limit, 50); const submissions: EmailSubmission[] = []; let rawPosition = 0; let rawTotal = 0; do { const queryResponse = await this.request([ ['EmailSubmission/query', { accountId: this.getSubmissionAccountId(), limit: pageSize, position: rawPosition, }, '0'], ]); const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined; const ids = query?.ids ?? []; rawTotal = query?.total ?? rawPosition + ids.length; if (ids.length === 0) break; const submissionResponse = await this.request([ ['EmailSubmission/get', { accountId: this.getSubmissionAccountId(), ids, properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'], }, '0'], ]); submissions.push(...((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[]) .filter(submission => { if (submission.undoStatus !== 'pending' || !submission.sendAt) return false; const sendAtTime = new Date(submission.sendAt).getTime(); return Number.isFinite(sendAtTime) && sendAtTime > now; })); rawPosition += ids.length; } while (rawPosition < rawTotal); submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime()); const total = submissions.length; const pageSubmissions = submissions.slice(position, position + limit); const nextPosition = position + pageSubmissions.length; if (pageSubmissions.length === 0) { return { emails: [], hasMore: false, total, nextPosition }; } const emailResponse = await this.request([ ['Email/get', { accountId: this.accountId, ids: pageSubmissions.map(submission => submission.emailId), properties: [ 'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo', 'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt', 'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure', ], fetchTextBodyValues: true, fetchHTMLBodyValues: true, fetchAllBodyValues: true, maxBodyValueBytes: 256000, }, '0'], ]); const emailById = new Map(((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]).map(email => [email.id, email])); const emails = pageSubmissions .map((submission): ScheduledEmail | null => { const email = emailById.get(submission.emailId); if (!email || !submission.sendAt) return null; return { ...email, threadId: submission.threadId || email.threadId, scheduledSendAt: submission.sendAt, emailSubmissionId: submission.id, scheduledIdentityId: submission.identityId, scheduledUndoStatus: submission.undoStatus, scheduledDeliveryStatus: submission.deliveryStatus, isScheduled: true, isSmimeScheduled: isSmimeEmail(email), }; }) .filter((email): email is ScheduledEmail => email !== null) .sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime()); return { emails, hasMore: nextPosition < total, total, nextPosition }; } async cancelEmailSubmission(submissionId: string): Promise { const response = await this.request([ ['EmailSubmission/set', { accountId: this.getSubmissionAccountId(), update: { [submissionId]: { undoStatus: 'canceled' } }, }, '0'], ]); const result = response.methodResponses?.[0]?.[1]; const error = result?.notUpdated?.[submissionId]; if (error) { throw new Error(error.description || error.type || 'Failed to cancel scheduled send'); } } async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise { const holdForSeconds = this.validateDelayedUntil(delayedUntil); const mailboxes = await this.getMailboxes(); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); const identities = await this.getIdentities(); const identity = identities.find(item => item.id === identityId); const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId); const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId); const envelopeRecipients = existingEnvelope?.rcptTo?.length ? existingEnvelope.rcptTo : [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])]; const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients); const response = await this.request([ ['EmailSubmission/set', { accountId: this.getSubmissionAccountId(), create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } }, ...(draftsMailbox && sentMailbox ? { onSuccessUpdateEmail: { '#replacement': { ...mailboxIdsReplacement(sentMailbox.id), 'keywords/$draft': null, }, }, } : {}), }, '0'], ]); const result = response.methodResponses?.[0]?.[1]; const createError = result?.notCreated?.replacement; if (createError) { throw new Error(createError.description || createError.type || 'Failed to reschedule email'); } const replacementId = result?.created?.replacement?.id; const serverSendAt = result?.created?.replacement?.sendAt; if (!replacementId) { throw new Error('Server did not return a replacement scheduled send ID'); } const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId); try { await this.cancelEmailSubmission(submissionId); } catch (error) { try { await this.cancelEmailSubmission(replacementId); } catch (cleanupError) { console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError); } const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send'; throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`); } return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt }; } // The third parameter is intentionally unused: this restores an undo-send / // canceled-scheduled message to be a draft, so it should live in Drafts *only*. // A full mailboxIds replacement (rather than mailboxIds/ pointer patches) // both drops the Sent copy without needing its id and stays safe for numeric // mailbox ids — see mailboxIdsReplacement(). async restoreEmailToDraft(emailId: string, draftMailboxId: string, _sentMailboxId?: string): Promise { const update: Record = { ...mailboxIdsReplacement(draftMailboxId), 'keywords/$draft': true, 'keywords/$seen': true, }; const response = await this.request([ ['Email/set', { accountId: this.accountId, update: { [emailId]: update }, }, '0'], ]); const result = response.methodResponses?.[0]?.[1]; const error = result?.notUpdated?.[emailId]; if (error) { throw new Error(error.description || error.type || 'Failed to restore email to drafts'); } } // ── PushSubscription (RFC 8620 §7.2) ────────────────────────────── // Used by the PWA Web Push integration. The mobile app does the same dance // through its own JMAP client - keep these in sync. async listPushSubscriptions(): Promise { const response = await this.request( [['PushSubscription/get', { ids: null }, '0']], ['urn:ietf:params:jmap:core'], ); const [, body] = response.methodResponses[0] ?? []; return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? []; } async createPushSubscription(params: { deviceClientId: string; url: string; types: string[]; expires?: string; }): Promise { const created: Record = { deviceClientId: params.deviceClientId, url: params.url, types: params.types, }; if (params.expires) created.expires = params.expires; const response = await this.request( [['PushSubscription/set', { create: { new: created } }, '0']], ['urn:ietf:params:jmap:core'], ); const [, body] = response.methodResponses[0] ?? []; const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined); const id = result?.created?.new?.id; if (!id) { throw new Error( `PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`, ); } return id; } async verifyPushSubscription(id: string, verificationCode: string): Promise { const response = await this.request( [['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']], ['urn:ietf:params:jmap:core'], ); const [, body] = response.methodResponses[0] ?? []; const notUpdated = (body as { notUpdated?: Record } | undefined)?.notUpdated?.[id]; if (notUpdated) { throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`); } } // Returns false when the server rejects the update (e.g. the subscription // was already destroyed) - the caller treats that as a signal to recreate. async updatePushSubscription( id: string, patch: { expires?: string; types?: string[] }, ): Promise { const response = await this.request( [['PushSubscription/set', { update: { [id]: patch } }, '0']], ['urn:ietf:params:jmap:core'], ); const [, body] = response.methodResponses[0] ?? []; const r = body as { updated?: Record; notUpdated?: Record } | undefined; if (r?.notUpdated?.[id]) return false; return r?.updated?.[id] !== undefined; } async destroyPushSubscription(id: string): Promise { await this.request( [['PushSubscription/set', { destroy: [id] }, '0']], ['urn:ietf:params:jmap:core'], ); } }