import { generateUUID } from '@/lib/utils'; import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { batched, itemsPerRequest } from "./request-limits"; import { keywordPointer } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; import { debug } from "@/lib/debug"; import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization"; import { SYNTHETIC_ID_PROBE, RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from "@/lib/recurrence-instances"; import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection"; import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection"; import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox"; import { decodeFileNodeName } from "./filenode-name"; import { contactFromWire, contactToWire } from "./contact-wire"; import { getEffectiveTimeZone, toLocalDateTime } from "@/lib/timezone"; import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order"; // Cap for the follow-up Email/get issued when a displayed body part comes // back truncated at the normal 256000-byte limit (see refetchTruncatedBodyValues // / #884). Bounded well above the default so it stays rare in practice while // still guarding against unbounded memory use on a malicious/oversized body. const TRUNCATED_BODY_REFETCH_MAX_BYTES = 8_000_000; // Names of nodes created over WebDAV come back percent-encoded from // FileNode/get (see decodeFileNodeName / #869). Normalize at the boundary so // every consumer (browser, sidebar, breadcrumbs, path resolution) sees the // human-readable name. function withDecodedName>(node: T): T { const name = decodeFileNodeName(node.name); return name === node.name ? node : { ...node, name }; } type LegacyFileNodeRights = { mayRead?: boolean; mayWrite?: boolean; mayShare?: boolean }; /** * Stalwart before 0.16.6 implements an older JMAP File Storage draft whose * rights are just mayRead / mayWrite / mayShare. Spread mayWrite over the * finer rights the UI checks. */ function fromLegacyRights(rights: FileNodeRights | LegacyFileNodeRights | undefined): FileNodeRights | undefined { if (!rights || !('mayWrite' in rights)) return rights as FileNodeRights | undefined; const write = !!rights.mayWrite; return { mayRead: !!rights.mayRead, mayAddChildren: write, mayRename: write, mayDelete: write, mayModifyContent: write, mayShare: !!rights.mayShare, }; } function toLegacyRights(rights: FileNodeRights): LegacyFileNodeRights { return { mayRead: rights.mayRead, mayWrite: rights.mayAddChildren || rights.mayRename || rights.mayDelete || rights.mayModifyContent, mayShare: rights.mayShare, }; } /** A FileNode as the server sent it, normalized for the UI. */ function fromWireFileNode(node: FileNode): FileNode { const decoded = withDecodedName(node); if (!decoded.myRights && !decoded.shareWith) return decoded; return { ...decoded, myRights: fromLegacyRights(decoded.myRights), shareWith: decoded.shareWith ? Object.fromEntries(Object.entries(decoded.shareWith).map(([p, r]) => [p, fromLegacyRights(r) as FileNodeRights])) : decoded.shareWith, }; } /** * Parse a recipient string that may be "Name " or bare "email" into * { name?, email }. The display name is unquoted and stripped of any address * it carries inline: JMAP takes the name and the address as separate fields, * so leaving the quoting or a second copy of the address in there makes the * server emit an invalid To/Cc mailbox, which it then re-parses into a * malformed envelope recipient (#672). */ function parseRecipientString(s: string): { name?: string; email: string } { return splitMailbox(s); } /** * 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; } } /** * A request produced no response headers within its deadline. * * `fetch()` has no timeout of its own, and a half-dead connection - the OS or a * NAT dropped the socket while the browser still believes it is usable - never * rejects. iOS hits this constantly: it freezes a backgrounded tab (and, more * aggressively, a home-screen web app), and on resume WebKit reuses pooled * connections the network has already torn down. The request bytes leave, the * server may even act on them, but the response never comes back. Without a * deadline the caller's promise stays pending forever, so the composer's Send * button stays disabled and "Save draft" never closes the composer (#702). */ export class RequestTimeoutError extends Error { constructor(timeoutMs: number) { super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`); this.name = 'RequestTimeoutError'; } } /** A scheduled send later than the server's hold limit. */ export class ScheduleTooLateError extends Error { constructor(readonly maxSeconds?: number) { super('Scheduled send time is later than the server allows'); this.name = 'ScheduleTooLateError'; } } /** * Stalwart advertises `maxDelayedSend` as a fixed 30 days, while its MTA * rejects any hold beyond the `futureRelease` limit, 7 days by default, * with "501 5.5.4 Requested hold time exceeds maximum of N seconds". */ const STALWART_ADVERTISED_MAX_DELAYED_SEND = 30 * 24 * 60 * 60; const STALWART_DEFAULT_MAX_HOLD = 7 * 24 * 60 * 60; /** The hold limit named in a rejected submission, if that was the reason. */ export function parseHoldLimit(description: string | undefined): number | null { const m = description?.match(/hold time exceeds maximum of (\d+) seconds/i); return m ? Number(m[1]) : null; } // 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; export const KEYWORDS_CAPABILITY = 'https://bulwarkmail.com/ns/jmap/keywords'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type JMAPResponseResult = Record; interface JMAPResponse { methodResponses: Array<[string, JMAPResponseResult, string]>; } /** Per-id failure entry of a CalendarEvent/set (`notCreated` / `notDestroyed`). */ export interface CalendarSetError { type?: string; description?: 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", // Attachment metadata (name / type / blobId) so list rows can offer the // files directly. hasAttachment alone only supports a paperclip icon. "attachments", // Needed so list rows can serve drag-out to the file system as .eml. "blobId", ] as const; /** * How many messages `discoverKeywords` walks before it gives up and reports an * incomplete scan. A keyword only has to sit on one message to be worth * recovering, so there is no cheaper place to stop than "all of them" - the cap * exists so a very large account cannot turn a settings page into an unbounded * page-by-page crawl, not because the tail is uninteresting. */ export const DEFAULT_KEYWORD_SCAN_LIMIT = 25000; // How long an inconclusive keyword-sort polarity probe (#718) is trusted // before the next folder fetch tries again. const KEYWORD_SORT_PROBE_RETRY_MS = 5 * 60 * 1000; // 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', 'baseEventId', ] 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', '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; const TASK_PROGRESS_NORMALIZATION = new Map([ ['needs-action', 'needs-action'], ['needs_action', 'needs-action'], ['in-process', 'in-process'], ['in_process', 'in-process'], ['completed', 'completed'], ['cancelled', 'cancelled'], ['canceled', 'cancelled'], ]); function normalizeCalendarTask(task: CalendarTask): CalendarTask { const progress = task.progress as unknown; if (typeof progress !== 'string') return task; const normalizedProgress = TASK_PROGRESS_NORMALIZATION.get(progress.toLowerCase()); return normalizedProgress === undefined ? task : { ...task, progress: normalizedProgress }; } /** * IANA time zone of the user - their `timeZone` setting when set (#755), * otherwise the browser's - 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 getEffectiveTimeZone() || 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; } } /** * RFC 8620 §3.6.1 limit error naming the server's parallel-request ceiling. * Stalwart refuses the surplus of a burst with 400 jmap:error:limit / * maxConcurrentRequests BEFORE any method runs, so the request is safe to * replay - even a /set. */ export function isConcurrentRequestRefusal(status: number, body: string): boolean { if (status !== 400) return false; try { const parsed = JSON.parse(body) as { type?: unknown; limit?: unknown } | null; return parsed?.type === 'urn:ietf:params:jmap:error:limit' && parsed?.limit === 'maxConcurrentRequests'; } catch { return false; } } /** Base back-off per attempt when the server refuses a request for being one too many in parallel. */ const CONCURRENT_REQUEST_RETRY_DELAYS_MS = [200, 400, 800]; /** Ids asked for per FileNode/query page; Stalwart clamps it to queryMaxResults (5000 by default). */ const FILE_NODE_QUERY_PAGE = 5000; /** Safety bound on how many FileNode ids one listing pages through. */ const FILE_NODE_MAX_IDS = 200_000; function computeHasMore(position: number, emailCount: number, total: number, limit: number): boolean { if (total > 0) return (position + emailCount) < total; return emailCount === limit; } interface JMAPMethodError { type?: string; description?: string; } /** * The method errors of a send request. JMAP names a failed call `"error"` * (RFC 8620 §3.6.2), not `"/error"`. An error after a created * EmailSubmission comes from its implicit onSuccessUpdateEmail / * onSuccessDestroyEmail call: the message has already left, so it is a filing * problem, not a failed send (reporting it as one invites a duplicate resend). */ export function sendMethodErrors( methodResponses: JMAPResponse['methodResponses'] | undefined, ): { failure?: JMAPMethodError; filing?: JMAPMethodError } { let submitted = false; let failure: JMAPMethodError | undefined; let filing: JMAPMethodError | undefined; for (const [name, result] of methodResponses ?? []) { if (name === 'EmailSubmission/set' && Object.keys(result?.created ?? {}).length > 0) { submitted = true; } else if (name === 'error') { if (submitted) filing ??= result; else failure ??= result; } } return { failure, filing }; } /** "report.pdf" -> "report (2).pdf", the way Stalwart's onExists "rename" names copies. */ function numberedFileName(name: string, n: number): string { const dot = name.lastIndexOf('.'); return dot > 0 ? `${name.slice(0, dot)} (${n})${name.slice(dot)}` : `${name} (${n})`; } /** Throw on a method error of the query that a search request starts with. */ function assertQuerySucceeded(response: JMAPResponse, what: string): void { const [name, result] = response.methodResponses?.[0] ?? []; if (name === 'error') { const error = new Error(`${what} failed: ${result?.description || result?.type || 'unknown error'}`); (error as Error & { jmapType?: string }).jmapType = result?.type; throw error; } } 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'; // FTM fix (2026-08-22): crypto.randomUUID() only exists in SECURE contexts (https/localhost). // On a plain-http LAN origin it is undefined, so this line threw AFTER the draft was saved and // BEFORE EmailSubmission/set — send silently failed with drafts piling up. generateUUID() from // lib/utils falls back to crypto.getRandomValues, which insecure contexts do provide. return `${Date.now().toString(36)}.${generateUUID()}@${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 { return sanitizeDisplayName(name); } 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 })); } /** * Per-message SMTP submission options (RFC 8621 §7.1 envelope parameters). * `requestDsn` asks the next hops for delivery status notifications (RFC * 3461 NOTIFY / RET); `requireTls` refuses relaying over an unencrypted hop * (RFC 8689 REQUIRETLS). Both need the matching entry in the account's * `submissionExtensions`; see JMAPClient.supportsSubmissionExtension. */ export interface SubmissionSendOptions { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; } /** The MAIL FROM / RCPT TO parameters the options translate to. */ export function submissionEnvelopeParameters(options: SubmissionSendOptions | undefined, holdForSeconds?: number): { mailFrom: Record; rcptTo: Record; } { const mailFrom: Record = {}; const rcptTo: Record = {}; if (holdForSeconds) mailFrom.HOLDFOR = String(holdForSeconds); if (options?.requireTls) mailFrom.REQUIRETLS = null; if (options?.requestDsn) { // Return the headers only (not the full message) with the notification. mailFrom.RET = 'HDRS'; rcptTo.NOTIFY = 'SUCCESS,FAILURE,DELAY'; } return { mailFrom, rcptTo }; } function createDelayedSubmissionEnvelope(fromEmail: string, holdForSeconds?: number, recipients?: Array, options?: SubmissionSendOptions): Record | undefined { const parameters = submissionEnvelopeParameters(options, holdForSeconds); if (Object.keys(parameters.mailFrom).length === 0 && Object.keys(parameters.rcptTo).length === 0) return undefined; const rcptTo = normalizeEnvelopeRecipients(recipients).map((r) => Object.keys(parameters.rcptTo).length > 0 ? { ...r, parameters: parameters.rcptTo } : r, ); return { mailFrom: { email: fromEmail, ...(Object.keys(parameters.mailFrom).length > 0 ? { parameters: parameters.mailFrom } : {}), }, rcptTo, }; } type SubmissionCapability = { maxDelayedSend?: number; submissionExtensions?: unknown; }; export class JMAPClient implements IJMAPClient { private static readonly RATE_LIMIT_TOAST_THROTTLE_MS = 10_000; /** * How long a request may go without producing response headers. Generous * enough that a slow mobile link or a busy server still completes, short * enough that a dead connection surfaces as an error the user can retry * rather than a permanently spinning UI. */ private static readonly REQUEST_TIMEOUT_MS = 30_000; /** * Blob transfers answer only once the payload has moved, so a large * attachment on a slow uplink legitimately outlives the normal deadline. */ private static readonly TRANSFER_TIMEOUT_MS = 300_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; /** Hold limit learned from a rejected scheduled send (seconds). */ private learnedMaxDelayedSend: number | null = null; // 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; // Per-account result of the keyword sort polarity probe (#718). `null` means // the probe was inconclusive (every message read, or unread) and is retried // after KEYWORD_SORT_PROBE_RETRY_MS; a determined value lives for the session. private keywordSortPolarity = new Map(); private keywordSortProbes = new Map>(); // Accounts whose server refused a hasKeyword comparator with unsupportedSort. private keywordSortUnsupported = new Set(); private lastRateLimitNoticeAt: number = 0; // Serialises the first calendar/contacts request per account so Stalwart's // lazy default-calendar/address-book creation can't run twice (#907). private firstTouchGate = new FirstTouchGate(); 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; } /** * `fetch` with a deadline on the response *headers*. * * The timer is cleared as soon as the fetch settles, so it never touches the * body: long-lived streams (SSE) and slow blob transfers keep working once * the server has started answering. What it does catch is the stalled case - * a connection that accepts the request and then goes silent - which fetch * itself would leave pending indefinitely. * * A caller-supplied `init.signal` still aborts the request (and, for SSE, the * stream) at any time; it is chained into the internal controller rather than * replaced. */ private async timedFetch( url: string, init: Parameters[1], headers: Record, timeoutMs: number, ): Promise { const controller = new AbortController(); const external = init?.signal ?? undefined; if (external) { if (external.aborted) controller.abort(external.reason); else external.addEventListener('abort', () => controller.abort(external.reason), { once: true }); } // Tracked separately from the signal: the abort reason is what distinguishes // "we gave up" from "the caller cancelled", and callers must be able to tell // those apart (a cancelled send is not a failed send). let timedOut = false; const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs); try { return await fetch(url, { ...init, headers, signal: controller.signal }); } catch (error) { if (timedOut) throw new RequestTimeoutError(timeoutMs); throw error; } finally { clearTimeout(timer); } } private async authenticatedFetch( url: string, init?: Parameters[1], opts?: { timeoutMs?: number }, ): 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 timeoutMs = opts?.timeoutMs ?? JMAPClient.REQUEST_TIMEOUT_MS; const headers = { ...init?.headers as Record, 'Authorization': this.authHeader }; let response: Response; try { response = await this.timedFetch(url, init, headers, timeoutMs); } catch (error) { // Network error: retry once after brief delay (transient proxy/connection issues) if (this.reconnecting) throw error; // A timeout is NOT retried. The request may well have reached the server // and been acted on - only the answer was lost - and these bodies are not // idempotent: replaying an EmailSubmission/set would send the mail twice. // Surface it instead so the caller can report a failure the user can act on. if (error instanceof RequestTimeoutError) throw error; await new Promise(r => setTimeout(r, 1000)); response = await this.timedFetch(url, init, headers, timeoutMs); } // 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 this.timedFetch(url, init, retryHeaders, timeoutMs); } } 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 this.timedFetch(url, init, retryHeaders, timeoutMs); } 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 this.timedFetch(url, init, retryHeaders, timeoutMs); } } 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' }); // A proxy in front of the session URL answers the header-less redirected // request with 401 instead of Stalwart's silent 200 - the credentials are // fine, Safari just dropped them. Retry the final URL directly. (#892) if (response.redirected && response.status === 401) { return fetch(response.url, { method: 'GET', headers: { 'Authorization': this.authHeader }, }); } 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 { if (!url) return url; try { const { origin } = new URL(this.serverUrl); if (!/^(https?:)?\/\//i.test(url)) { return origin + (url.startsWith('/') ? url : '/' + url); } const pathStart = url.indexOf('/', url.indexOf('//') + 2); return origin + (pathStart === -1 ? '' : url.slice(pathStart)); } catch { return url; } } private rewriteSessionUrls(session: JMAPSession): void { session.apiUrl = this.rewriteSessionUrl(session.apiUrl); session.downloadUrl = this.rewriteSessionUrl(session.downloadUrl); if (session.uploadUrl) { session.uploadUrl = this.rewriteSessionUrl(session.uploadUrl); } if (session.eventSourceUrl) { session.eventSourceUrl = this.rewriteSessionUrl(session.eventSourceUrl); } } private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise { 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 body = JSON.stringify(requestBody); const apiUrl = this.apiUrl; for (let attempt = 0; ; attempt++) { const response = await this.firstTouchGate.run(methodCalls, () => this.authenticatedFetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body, }), ); const responseText = await response.text(); if (!response.ok) { // Stalwart caps parallel API requests per user (maxConcurrentRequests, // default 4). One push event fans out into several refreshes at once // (folders, tag counts, thread counts, the list) - multiplied by open // tabs - so the ceiling is reached in ordinary use. The refusal comes // before any method runs, so back off briefly and replay instead of // failing a refresh outright (which used to blank the folder tree, #780). if (attempt < CONCURRENT_REQUEST_RETRY_DELAYS_MS.length && isConcurrentRequestRefusal(response.status, responseText)) { const base = CONCURRENT_REQUEST_RETRY_DELAYS_MS[attempt]; await new Promise(r => setTimeout(r, base + Math.random() * base)); continue; } console.error('Request failed:', response.status, responseText); throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`); } try { return JSON.parse(responseText); } catch { console.error('Failed to parse response:', responseText); throw new Error('Invalid JSON response from server'); } } } 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) { // Never substitute a placeholder here. A refused request (the server's // maxConcurrentRequests ceiling during a push-driven refresh burst) used // to yield a lone synthetic "Inbox" with a made-up id, and the store then // replaced the real folder tree with it: the sidebar showed only Inbox // (empty, since the id matched nothing) until a reload (#780). Callers // keep what they already have, or surface the failure. console.error('Failed to get mailboxes:', error); throw error instanceof Error ? error : new Error('Failed to get mailboxes'); } } async getAllMailboxes(): Promise { return (await this.getAllMailboxesWithState()).mailboxes; } /** * Like getAllMailboxes, but also returns the Mailbox collection `state` * (RFC 8620 §5.1) of every account fetched, keyed by accountId. The store * remembers those states so a later push can be resolved with * Mailbox/changes (delta) instead of re-fetching every folder tree. */ async getAllMailboxesWithState(): Promise<{ mailboxes: Mailbox[]; states: Record }> { try { const allMailboxes: Mailbox[] = []; const states: Record = {}; const accountIds = Object.keys(this.accounts); if (accountIds.length === 0) { return { mailboxes: await this.getMailboxes(), states }; } 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[]; const mailboxState = (response.methodResponses[0][1] as { state?: unknown }).state; if (typeof mailboxState === 'string') { states[accountId] = mailboxState; } 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 { mailboxes: allMailboxes, states }; } catch (error) { // No placeholder fallback (see getMailboxes): the caller keeps its // current list rather than showing a folder tree the server never sent. console.error("Failed to fetch all mailboxes:", error); throw error instanceof Error ? error : new Error('Failed to fetch mailboxes'); } } /** * Maps a raw JMAP Mailbox into the store's shape. Folders of a delegated * (non-primary) account get namespaced ids (`accountId:id`) exactly like * getAllMailboxes produces them, so a delta-patched folder matches the row * it replaces. */ private mapRawMailbox(mb: JMAPMailbox, accountId: string): Mailbox { const isPrimary = accountId === this.accountId; const account = this.accounts[accountId]; return { 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; } /** * Fetches only the given mailboxes (Mailbox/get with `ids`), mapped like * getAllMailboxes. Used to patch the folders a Mailbox/changes response * reported as updated without re-fetching the whole tree. */ async getMailboxesByIds(ids: string[], accountId?: string): Promise { const acctId = accountId || this.accountId; if (ids.length === 0) return []; const mailboxes: Mailbox[] = []; for (const batchIds of batched(ids, this.getMaxObjectsInGet())) { const response = await this.request([ ["Mailbox/get", { accountId: acctId, ids: batchIds }, "0"], ]); if (response.methodResponses?.[0]?.[0] !== "Mailbox/get") { throw new Error('Unexpected response format'); } const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[]; mailboxes.push(...rawMailboxes.map((mb) => this.mapRawMailbox(mb, acctId))); } return mailboxes; } /** * Generic `Foo/changes` (RFC 8620 §5.2). Returns null when the server can * no longer compute a delta from `sinceState` (`cannotCalculateChanges`, * a too-old state, or any other error) - the caller then falls back to a * full re-fetch. `hasMoreChanges` is surfaced rather than paged through: * a delta that large is cheaper to resolve with a fresh query. */ private async getCollectionChanges(type: 'Email' | 'Mailbox', sinceState: string, accountId?: string, maxChanges?: number): Promise { const targetAccountId = accountId || this.accountId; try { const response = await this.request([ [`${type}/changes`, { accountId: targetAccountId, sinceState, ...(maxChanges ? { maxChanges } : {}), }, "0"], ]); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== `${type}/changes` || !result) { return null; } const r = result as { oldState?: string; newState?: string; hasMoreChanges?: boolean; created?: string[]; updated?: string[]; destroyed?: string[]; updatedProperties?: string[] | null; }; if (typeof r.newState !== 'string') return null; return { oldState: r.oldState ?? sinceState, newState: r.newState, hasMoreChanges: r.hasMoreChanges === true, created: r.created ?? [], updated: r.updated ?? [], destroyed: r.destroyed ?? [], updatedProperties: Array.isArray(r.updatedProperties) ? r.updatedProperties : null, }; } catch (error) { debug.log('jmap', `[JMAP] ${type}/changes since ${sinceState} unavailable, falling back to a full fetch`, error); return null; } } /** Email/changes since `sinceState` for the account (see getCollectionChanges). */ getEmailChanges(sinceState: string, accountId?: string, maxChanges?: number): Promise { return this.getCollectionChanges('Email', sinceState, accountId, maxChanges); } /** Mailbox/changes since `sinceState` for the account (see getCollectionChanges). */ getMailboxChanges(sinceState: string, accountId?: string, maxChanges?: number): Promise { return this.getCollectionChanges('Mailbox', sinceState, accountId, maxChanges); } /** * Sort properties the server advertises for Email/query on this account * (RFC 8621 §1.3 `emailQuerySortOptions`), or null when it doesn't say. */ getEmailQuerySortOptions(accountId?: string): string[] | null { const id = accountId || this.accountId; const caps = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:mail"] as | { emailQuerySortOptions?: unknown } | undefined; const options = caps?.emailQuerySortOptions; return Array.isArray(options) ? options.filter((o): o is string => typeof o === "string") : null; } /** * Whether keyword comparators (hasKeyword) can be sent to this account's * server: not when it advertises a sort-option list without them, and not * after it has refused one with `unsupportedSort`. Unknown counts as yes. */ private keywordSortSupported(accountId: string): boolean { if (this.keywordSortUnsupported.has(accountId)) return false; const options = this.getEmailQuerySortOptions(accountId); return options === null || options.includes("hasKeyword"); } /** * Determines, once per account and session, which way the server reads * `isAscending` on hasKeyword comparators (see KeywordSortPolarity in * lib/message-list-order.ts). Two one-item queries with opposite * directions: if they return different messages both groups exist and the * keywords of the ascending winner settle the polarity; if they return the * same message the mailbox is homogeneous and the probe is inconclusive. * Any failure falls back to the RFC reading without caching a verdict. */ private resolveKeywordSortPolarity(accountId: string): Promise { const cached = this.keywordSortPolarity.get(accountId); if (cached && (cached.value !== null || Date.now() - cached.at < KEYWORD_SORT_PROBE_RETRY_MS)) { return Promise.resolve(cached.value ?? "rfc"); } const pending = this.keywordSortProbes.get(accountId); if (pending) return pending; const probe = this.probeKeywordSortPolarity(accountId) .then((value) => { this.keywordSortPolarity.set(accountId, { value, at: Date.now() }); return value ?? "rfc"; }) .catch((error) => { debug.warn("jmap", "Keyword sort polarity probe failed:", error); this.keywordSortPolarity.set(accountId, { value: null, at: Date.now() }); return "rfc" as const; }) .finally(() => { this.keywordSortProbes.delete(accountId); }); this.keywordSortProbes.set(accountId, probe); return probe; } private async probeKeywordSortPolarity(accountId: string): Promise { const sortFor = (isAscending: boolean) => [ { property: "hasKeyword", keyword: "$seen", isAscending }, { property: "receivedAt", isAscending: false }, ]; const response = await this.request([ ["Email/query", { accountId, sort: sortFor(true), limit: 1 }, "asc"], ["Email/query", { accountId, sort: sortFor(false), limit: 1 }, "desc"], ["Email/get", { accountId, "#ids": { resultOf: "asc", name: "Email/query", path: "/ids" }, properties: ["id", "keywords"], }, "get"], ]); const [ascMethod, ascResult] = response.methodResponses?.[0] ?? []; if (ascMethod === "error") { if ((ascResult as { type?: string })?.type === "unsupportedSort") { this.keywordSortUnsupported.add(accountId); return "rfc"; } throw new Error((ascResult as { description?: string })?.description || "Email/query failed"); } const ascIds = ((ascResult as { ids?: string[] })?.ids) ?? []; const descIds = ((response.methodResponses?.[1]?.[1] as { ids?: string[] })?.ids) ?? []; if (ascIds.length === 0 || descIds.length === 0 || ascIds[0] === descIds[0]) return null; const first = ((response.methodResponses?.[2]?.[1] as { list?: Email[] })?.list ?? [])[0]; if (!first || first.id !== ascIds[0]) return null; // Ascending put a $seen message first: the server sorts has-keyword first // on isAscending: true, the inverse of the RFC's false < true reading. return first.keywords?.$seen ? "inverted" : "rfc"; } /** The Email/query sort for a folder view, with the server's polarity applied. */ private async buildListSort(accountId: string, pinnedFirst: boolean, order: SortLevel[]): Promise<{ sort: ReturnType; keywordSortSupported: boolean }> { if (!hasKeywordLevels(order, pinnedFirst)) { return { sort: buildEmailSort(order), keywordSortSupported: true }; } if (!this.keywordSortSupported(accountId)) { return { sort: buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }), keywordSortSupported: false }; } const polarity = await this.resolveKeywordSortPolarity(accountId); return { sort: buildEmailSort(order, { pinnedFirst, polarity }), keywordSortSupported: true }; } async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number, state?: string }> { 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 and the configured list order (#718) use the hasKeyword // sort comparator (RFC 8621 §4.4.2); every page of a view must use the // same sort or pagination tears, so the sort is built from settings the // same way on the first page, load-more and the push refresh. const built = await this.buildListSort(targetAccountId, pinnedFirst === true, order); const query = (sort: ReturnType) => 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"], ]); let response = await query(built.sort); // A server that advertises nothing but refuses hasKeyword fails the whole // query. Remember that and retry once without the keyword comparators // rather than showing an empty folder. if ( built.keywordSortSupported && response.methodResponses?.[0]?.[0] === "error" && (response.methodResponses[0][1] as { type?: string })?.type === "unsupportedSort" ) { this.keywordSortUnsupported.add(targetAccountId); response = await query(buildEmailSort(order, { pinnedFirst, keywordSortSupported: false })); } 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 configured order. emails.sort(compareEmails(order, { pinnedFirst })); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); if (accountId && accountId !== this.accountId) { namespaceMailboxIds(emails, accountId); } // The Email collection state this page was read at; the store keeps // it so a later push can be applied with Email/changes. const state = typeof getResponse.state === 'string' ? getResponse.state : undefined; return { emails, hasMore, total, state }; } return { emails: [], hasMore: false, total: 0 }; } catch (error) { console.error('Failed to get emails:', error); return { emails: [], hasMore: false, total: 0 }; } } async getEmailsInMailbox(mailboxId: string): Promise { 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[], accountId?: string): Promise> { if (tagIds.length === 0) return {}; const result: Record = {}; const targetAccountId = accountId || this.accountId; 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: targetAccountId, filter: { hasKeyword: keyword }, limit: 0, calculateTotal: true, }, `total_${i}`]); // Unread count for this tag methodCalls.push(["Email/query", { accountId: targetAccountId, 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; } /** * Every keyword the account's messages actually carry, and how many messages * carry each. * * JMAP has no "list the keywords in use" call - a keyword exists only as a * property of the messages bearing it - so the only way to find them is to * walk the message list and union what turns up. Each page is one request: * an Email/query for the next slice of ids and an Email/get back-referencing * it, asking for nothing but `keywords`. * * Counting here rather than following up with `getTagCounts` costs nothing * extra - the messages are already in hand - and counts whatever spelling the * keyword actually has, which a `$label:`-shaped count query cannot do for a * keyword still written the legacy way. The trade is that a count is only * over the messages walked, so it is a floor rather than a total whenever * `complete` is false. * * The walk is capped at `limit` messages because an account can hold far more * mail than is worth paging through for this; `complete` says whether the cap * (or an abort) cut the scan short, so a caller can say so rather than * present a partial answer as the whole truth. Reading is all this does, and * it stops at the first failed page instead of retrying - a scan that ends * early is reported as incomplete, which is exactly what it is. */ async discoverKeywords(options?: { limit?: number; onProgress?: (scanned: number, total: number) => void; signal?: AbortSignal; }): Promise<{ keywords: Record; scanned: number; total: number; complete: boolean }> { const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT); const pageSize = Math.max(1, Math.min(500, this.getMaxObjectsInGet())); const keywords: Record = {}; let scanned = 0; let total = 0; let complete = false; while (scanned < cap) { if (options?.signal?.aborted) break; try { const response = await this.request([ ["Email/query", { accountId: this.accountId, sort: [{ property: "receivedAt", isAscending: false }], limit: Math.min(pageSize, cap - scanned), position: scanned, calculateTotal: scanned === 0, }, "0"], ["Email/get", { accountId: this.accountId, "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: ["keywords"], }, "1"], ]); const queryResponse = response.methodResponses?.[0]?.[1]; const getResponse = response.methodResponses?.[1]?.[1]; const ids: string[] = queryResponse?.ids || []; if (scanned === 0) total = queryResponse?.total ?? 0; const list = (getResponse?.list || []) as Array<{ keywords?: Record }>; for (const email of list) { for (const [keyword, isSet] of Object.entries(email.keywords || {})) { if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1; } } // Page by what the query returned, not by what the get did: a message // destroyed between the two lands in `notFound` and would otherwise // shift every later page by one and skip a message per gap. scanned += ids.length; options?.onProgress?.(scanned, Math.max(total, scanned)); // A short page is the end of the list, however much `total` claimed. if (ids.length === 0 || ids.length < Math.min(pageSize, cap - (scanned - ids.length))) { complete = true; break; } } catch (error) { console.error('Failed to scan keywords:', error); break; } } return { keywords, scanned, total: Math.max(total, scanned), complete }; } /** * Extension-facing keyword enumeration. * * A JMAP server can advertise a narrow capability that enumerates all cached * keywords in one request, including provider labels with no messages. * Servers without the capability retain the existing message walk as a * transparent fallback. */ async getKeywords(options?: { limit?: number; onProgress?: (scanned: number, total: number) => void; signal?: AbortSignal; }): Promise { const supportsKeywordGet = this.hasCapability(KEYWORDS_CAPABILITY) && this.hasAccountCapability(KEYWORDS_CAPABILITY); if (!supportsKeywordGet) { const scan = await this.discoverKeywords(options); const labels: KeywordInfo[] = Object.entries(scan.keywords).map(([id, total]) => ({ id, name: id.startsWith('$label:') ? id.slice('$label:'.length) : id, color: null, total, unread: 0, isProviderLabel: false, source: 'message', })); return { ...scan, labels }; } if (options?.signal?.aborted) { return { keywords: {}, labels: [], scanned: 0, total: 0, complete: false }; } const response = await this.request( [["Keyword/get", { accountId: this.accountId }, "0"]], ["urn:ietf:params:jmap:core", KEYWORDS_CAPABILITY], ); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== 'Keyword/get' || !Array.isArray(result?.list)) { const description = typeof result?.description === 'string' ? `: ${result.description}` : ''; throw new Error(`JMAP Keyword/get failed${description}`); } const labels = (result.list as KeywordInfo[]).map((item) => ({ ...item })); const keywords = Object.fromEntries(labels.map((item) => [item.id, item.total])); const total = typeof result.totalEmails === 'number' ? result.totalEmails : 0; options?.onProgress?.(total, total); return { keywords, labels, scanned: total, total, complete: true }; } /** * 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); } await this.refetchTruncatedBodyValues([email], targetAccountId); if (email.headers) { await this.parseEmailHeaders(email); } return email; } catch (error) { console.error('Failed to get email:', error); return null; } } // The 256000-byte body cap keeps list/thread fetches fast for the common // case, but truncation lands mid-tag on large HTML bodies (e.g. a data:image // src cut in half), which drops the whole element and can render blank. Only // the parts actually shown to the user are checked — fetchAllBodyValues also // returns truncated text from attachment parts, which must not trigger a // refetch. See #884. private hasTruncatedDisplayedBody(email: Email): boolean { const bodyValues = email.bodyValues; if (!bodyValues) return false; const htmlPartId = email.htmlBody?.[0]?.partId; const textPartId = email.textBody?.[0]?.partId; return Boolean( (htmlPartId && bodyValues[htmlPartId]?.isTruncated) || (textPartId && bodyValues[textPartId]?.isTruncated) ); } private async refetchTruncatedBodyValues(emails: Email[], accountId: string): Promise { const truncated = emails.filter((email) => this.hasTruncatedDisplayedBody(email)); if (truncated.length === 0) return; try { const refetchedById = new Map(); for (const batchIds of batched(truncated.map((email) => email.id), this.getMaxObjectsInGet())) { const response = await this.request([ ["Email/get", { accountId, ids: batchIds, properties: ["id", "bodyValues"], fetchTextBodyValues: true, fetchHTMLBodyValues: true, fetchAllBodyValues: true, maxBodyValueBytes: TRUNCATED_BODY_REFETCH_MAX_BYTES, }, "0"], ]); for (const refetched of (response.methodResponses?.[0]?.[1]?.list || []) as Email[]) { refetchedById.set(refetched.id, refetched.bodyValues); } } for (const email of truncated) { const bodyValues = refetchedById.get(email.id); if (bodyValues) email.bodyValues = bodyValues; } } catch (error) { // Degrade to the already-truncated values - no new error path for callers. console.error('Failed to refetch truncated body values:', error); } } 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 ? true : null, }, }, }, "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 ? true : null }])); 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 ? true : null, }, }, }, "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]: { [keywordPointer(keyword)]: true, }, }, }, "0"], ]); } async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise { await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: { [emailId]: { [keywordPointer(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 seen = new Set(); const allIds: string[] = []; let position = 0; const batchSize = 100; // Positions only line up within one queryState (RFC 8620 section 5.5). // Mail arriving mid-walk shifts every later page, so a changed state // restarts the walk - bounded, since a busy mailbox could change forever. let queryState: string | undefined; let restarts = 0; const maxRestarts = 3; while (true) { const response = await this.request([ ["Email/query", { accountId: this.accountId, filter: { hasKeyword: oldKeyword }, limit: batchSize, position, }, "0"], ]); const [queryMethod, queryResult] = response.methodResponses?.[0] || []; if (queryMethod === "error") { // Reading a refusal as "no messages carry the tag" would report a // clean migration of mail that still has the old keyword. throw new Error(queryResult?.description || "Failed to list emails with the keyword"); } const state = queryResult?.queryState as string | undefined; if (queryState === undefined) { queryState = state; } else if (state !== undefined && state !== queryState) { if (restarts >= maxRestarts) { // Carrying on would mix ids from states that no longer agree, and // retagging a message the tag has since been taken off is worse than // not retagging at all. Nothing has been written yet, so this is a // clean failure the caller can simply repeat. throw new Error("The mailbox kept changing while listing the messages to retag"); } restarts++; queryState = state; seen.clear(); allIds.length = 0; position = 0; continue; } const ids: string[] = queryResult?.ids || []; const fresh = ids.filter(id => !seen.has(id)); for (const id of fresh) seen.add(id); allIds.push(...fresh); // A server may return fewer ids than the limit asked for (RFC 8620 // section 5.5 lets it clamp), so a short page does not end the walk. // A page carrying nothing new under an unchanged state does: the walk // is not advancing, and continuing would loop for as long as the server // keeps answering. Position counts the server's own page, not the ids // this walk had not seen before. if (fresh.length === 0) break; position += ids.length; } if (allIds.length === 0) return { migrated: 0, refused: 0 }; // Batch update: remove old keyword, add new keyword using per-property patches let migrated = 0; let refusals = 0; // A whole batch the server refused to take: it will not take the next one // either. A single message it refused: the rest of the mail is unaffected, // and only the reason is worth keeping. let stopped: Error | null = null; let refusal: Error | null = null; for (const batch of batched(allIds, this.getMaxObjectsInSet())) { if (stopped) { refusals += batch.length; continue; } const update: Record> = {}; for (const id of batch) { update[id] = { [keywordPointer(oldKeyword)]: null, [keywordPointer(newKeyword)]: true, }; } const response = await this.request([ ["Email/set", { accountId: this.accountId, update, }, "0"], ]); const [setMethod, setResult] = response.methodResponses?.[0] || []; if (setMethod === "error") { stopped = new Error(setResult?.description || "Failed to migrate keyword"); refusals += batch.length; continue; } // Email/set applies per message, so a refusal is never all-or-nothing: // throwing here would abandon messages this very call already migrated. // Report instead - a message deleted since the query (notFound) is // nothing to report, any other refusal leaves the old keyword in place // on mail the caller would otherwise treat as migrated. const notUpdated = (setResult?.notUpdated || {}) as Record; const failed = Object.entries(notUpdated); const refused = failed.filter(([, error]) => error?.type !== 'notFound'); if (refused.length > 0) { debug.warn('keywords', 'Keyword migration refused for', refused.length, 'message(s):', refused[0][1]); refusal = refusal || new Error(refused[0][1]?.description || refused[0][1]?.type || "Failed to migrate keyword"); } refusals += refused.length; migrated += batch.length - failed.length; } // Nothing moved: the caller must not rename the tag over messages that all // kept the old keyword. Something moved: that is a partial migration the // caller reports, not an error - the messages that did move are migrated // and renaming the definition is what keeps them named. if (migrated === 0 && (stopped || refusal)) throw (stopped || refusal) as Error; return { migrated, refused: refusals }; } /** * Reject an Email/set response that reports a method-level error or per-id * `notUpdated`/`notDestroyed` failures. `request()` only throws on HTTP * errors, so without this the store removes rows the server refused. (#956) */ private assertEmailSetSucceeded(response: JMAPResponse, action: string): void { const [name, result] = response.methodResponses?.[0] ?? []; if (name === "error") { throw new Error(`Failed to ${action}: ${result?.description || result?.type || 'unknown error'}`); } const failed: Record = { ...(result?.notUpdated ?? {}), ...(result?.notDestroyed ?? {}), }; const failedIds = Object.keys(failed); if (failedIds.length === 0) return; const first = failed[failedIds[0]]; const reason = first?.description || first?.type || 'unknown error'; throw new Error( failedIds.length === 1 ? `Failed to ${action}: ${reason}` : `Failed to ${action} ${failedIds.length} email(s); first error: ${reason}`, ); } async deleteEmail(emailId: string, accountId?: string): Promise { const response = await this.request([ ["Email/set", { accountId: accountId || this.accountId, destroy: [emailId], }, "0"], ]); this.assertEmailSetSucceeded(response, 'delete email'); } 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; const response = await this.request([ ["Email/set", { accountId: targetAccountId, update: { [emailId]: patch }, }, "0"], ]); this.assertEmailSetSucceeded(response, 'move email to trash'); } async batchDeleteEmails(emailIds: string[], accountId?: string): Promise { if (emailIds.length === 0) return; for (const batch of batched(emailIds, this.getMaxObjectsInSet())) { const response = await this.request([ ["Email/set", { accountId: accountId || this.accountId, destroy: batch, }, "0"], ]); this.assertEmailSetSucceeded(response, 'delete emails'); } } 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()])); const response = await this.request([ ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], ]); this.assertEmailSetSucceeded(response, 'move emails'); } } 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; // Move + flag in one patch so other JMAP clients (and the server's spam // classifier) see the message as junk, not just filed in Junk. (#850) const patch: Record = { mailboxIds: { [mailboxId]: true }, "keywords/$junk": true, "keywords/$notjunk": null, }; 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 }, // Moving alone leaves $junk set, so the message shows up in the // Inbox while still flagged as junk for every other client. (#850) "keywords/$junk": null, "keywords/$notjunk": true, }, }, }, "0"], ]); } async createMailbox(name: string, parentId?: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const createId = `new-${Date.now()}`; // Subscribe explicitly: IMAP clients that list folders via LSUB // (Thunderbird) hide unsubscribed mailboxes, and the server default is // not guaranteed to be true. (#951) const createData: Record = { name, isSubscribed: true }; if (parentId) { createData.parentId = parentId; } const response = await this.request([ ["Mailbox/set", { accountId: targetAccountId, 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: targetAccountId, accountName: this.accounts[targetAccountId]?.name || (targetAccountId === this.accountId ? this.username : targetAccountId), isShared: targetAccountId !== this.accountId, }; } async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Mailbox/set", { accountId: targetAccountId, 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'}`); } } /** * Destroys a folder. By default the server refuses a non-empty folder * (`mailboxHasEmail`); with `removeEmails` the messages that are only in * this folder go with it (`onDestroyRemoveEmails`, RFC 8621 §2.5). */ async deleteMailbox(mailboxId: string, accountId?: string, options?: { removeEmails?: boolean }): Promise { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Mailbox/set", { accountId: targetAccountId, destroy: [mailboxId], ...(options?.removeEmails ? { onDestroyRemoveEmails: true } : {}), }, "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 }> { 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"], this.searchSnippetCall(targetAccountId, filter), ]); assertQuerySucceeded(response, 'Search'); 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); attachSearchSnippets(emails, this.searchSnippetsFrom(response)); // 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) { // Rethrow: an empty result would read as "No results found". console.error('Search failed:', error); throw error; } } /** * The SearchSnippet/get call (RFC 8621 §5) chained onto a search request, * or a Core/echo no-op when the filter has no term the server could * highlight (a purely structural filter yields empty snippets). Reads the * ids straight from the Email/query result, so it costs no extra round * trip; a failure of this call alone leaves the results without snippets. */ private searchSnippetCall(accountId: string, filter: Record | undefined): JMAPMethodCall { if (!filter || !filterHasSnippetTerms(filter)) { return ["Core/echo", {}, "snippets"]; } return ["SearchSnippet/get", { accountId, filter: snippetFilterFor(filter), "#emailIds": { resultOf: "0", name: "Email/query", path: "/ids" }, }, "snippets"]; } /** The SearchSnippet list of a search response, if the call succeeded. */ private searchSnippetsFrom(response: JMAPResponse): SearchSnippetResult[] | undefined { for (const [method, result, callId] of response.methodResponses ?? []) { if (callId === "snippets" && method === "SearchSnippet/get") { return ((result as { list?: SearchSnippetResult[] })?.list) ?? undefined; } } return undefined; } 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; // Searching all folders with no criteria yields an empty FilterCondition, // which servers reject; omit the key entirely to mean "no filter". const hasFilter = Object.keys(filter).length > 0; const response = await this.request([ ["Email/query", { accountId: targetAccountId, ...(hasFilter ? { 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"], this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined), ]); assertQuerySucceeded(response, 'Search'); 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); attachSearchSnippets(emails, this.searchSnippetsFrom(response)); // 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) { // One batched refetch for the whole thread rather than per-message, // to avoid an N+1 request pattern on threads with several oversized // messages (#884). await this.refetchTruncatedBodyValues(emails, targetAccountId); 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: { name?: string; email: string }[]; cc?: { name?: string; email: string }[]; bcc?: { name?: string; 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 }], // "Name " must be split into the two JMAP fields: storing the whole // mailbox as the address makes the server treat the display name as part // of the addr-spec, and the draft goes out to `Name ({ blobId: att.blobId, type: att.type, name: att.name, disposition: att.disposition ?? "attachment", ...(att.cid ? { cid: att.cid } : {}), })); } // Destroy the previous draft version only AFTER the replacement was // created (#849). A combined `Email/set { create, destroy }` processes // the two independently: when the create references part blobs of the // previous version (re-opened draft with attachments) and fails with // blobNotFound, the destroy still went through - deleting the last good // copy of the draft. Worst case now is an orphaned old version. const response = await this.request([ ["Email/set", { accountId: this.accountId, create: { [emailId]: emailData }, }, "0"], ]); 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 (result.created?.[emailId]) { const newDraftId = result.created[emailId].id; if (draftId) { try { const destroyResponse = await this.request([ ["Email/set", { accountId: this.accountId, destroy: [draftId] }, "0"], ]); const destroyResult = destroyResponse.methodResponses?.[0]?.[1]; if (destroyResult?.notDestroyed) { console.warn('Failed to destroy old draft:', destroyResult.notDestroyed); } } catch (err) { console.warn('Failed to destroy old draft:', err); } } return newDraftId; } } 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?: SubmissionSendOptions ): 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 }; const parameters = submissionEnvelopeParameters(options, holdForSeconds); const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0; const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0; if (hasMailFromParams || hasRcptToParams || envelopeMailFrom) { const envelopeRecipients = normalizeEnvelopeRecipients([...to, ...(cc || []), ...(bcc || [])]); create.envelope = { mailFrom: { email: parseRecipientString(envelopeMailFrom || fromEmail || this.username).email, ...(hasMailFromParams ? { parameters: parameters.mailFrom } : {}), }, rcptTo: hasRcptToParams ? envelopeRecipients.map((r) => ({ ...r, parameters: parameters.rcptTo })) : envelopeRecipients, }; } return { [submissionId]: create }; }; // The old draft is destroyed in a separate request only after the // submission succeeded (see below, #849). Destroying it up front in the // same request meant a failed create/submission - e.g. the outgoing email // referencing part blobs of that very draft, which die with it - still // deleted the user's only copy of the message. 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; const { failure, filing } = sendMethodErrors(response.methodResponses); if (failure) { console.error('[sendEmail] JMAP method error:', failure); throw new Error(failure.description || `Failed to send email: ${failure.type}`); } if (filing) { console.error('[sendEmail] post-send method error:', filing); filingError = filing.description || filing.type || 'post-send filing failed'; } if (response.methodResponses) { for (const [methodName, result] of response.methodResponses) { 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), ); this.throwIfHoldTooLong(firstError); 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; } } } // The message is out (or scheduled) - now it is safe to drop the old // draft. A failure here leaves an orphan in Drafts, which is reported as // a filing warning rather than a failed send (#849). if (draftId && createdEmailId) { try { const destroyResponse = await this.request([ ["Email/set", { accountId: this.accountId, destroy: [draftId] }, "0"], ]); const destroyResult = destroyResponse.methodResponses?.[0]?.[1]; if (destroyResult?.notDestroyed && Object.keys(destroyResult.notDestroyed).length) { console.error('[sendEmail] old draft cleanup notDestroyed:', JSON.stringify(destroyResult.notDestroyed, null, 2)); const first = Object.values(destroyResult.notDestroyed as Record)[0]; filingError = filingError ?? (first?.description || first?.type || 'old draft cleanup failed'); } } catch (err) { console.error('[sendEmail] old draft cleanup failed:', err); filingError = filingError ?? 'old draft cleanup failed'; } } // The account the submission was created in — the shared account when // sending from a shared identity. Returned so undo/send-now can address it // directly instead of searching for it. See #874. const submissionAccountId = this.getSubmissionAccountId(targetAccountId); if (delayedUntil && emailSubmissionId && !serverSendAt) { serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId); } return delayedUntil ? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, filingError } : { scheduled: false, emailId: createdEmailId, emailSubmissionId, submissionAccountId, 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)); const { failure: replyError } = sendMethodErrors(response.methodResponses); if (replyError) { debug.error('[iMIP] method error:', replyError); throw new Error(replyError.description || `iMIP reply failed: ${replyError.type}`); } if (response.methodResponses) { for (const [, result] of response.methodResponses) { 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); const { failure: invitationError } = sendMethodErrors(response.methodResponses); if (invitationError) { throw new Error(invitationError.description || `iMIP invitation failed: ${invitationError.type}`); } if (response.methodResponses) { for (const [, result] of response.methodResponses) { 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); const { failure: cancellationError } = sendMethodErrors(response.methodResponses); if (cancellationError) { throw new Error(cancellationError.description || `iMIP cancellation failed: ${cancellationError.type}`); } if (response.methodResponses) { for (const [, result] of response.methodResponses) { 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, }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); 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, {}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); 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; if (typeof maxDelayedSend !== 'number' || maxDelayedSend <= 0) return 0; if (this.learnedMaxDelayedSend !== null) return Math.min(maxDelayedSend, this.learnedMaxDelayedSend); if ( maxDelayedSend === STALWART_ADVERTISED_MAX_DELAYED_SEND && this.hasAccountCapability('urn:stalwart:jmap', accountId) ) { return STALWART_DEFAULT_MAX_HOLD; } return maxDelayedSend; } /** * Turn a "hold time exceeds maximum" rejection into ScheduleTooLateError * and remember the limit, so the picker offers only times that work. */ private throwIfHoldTooLong(error: { description?: string } | undefined): void { const limit = parseHoldLimit(error?.description); if (limit === null) return; this.learnedMaxDelayedSend = limit; throw new ScheduleTooLateError(limit); } 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 ScheduleTooLateError(maxDelayedSend); } return Math.ceil((time - now) / 1000); } private async getEmailSubmissionSendAt(submissionId: string, accountId?: string): Promise { const response = await this.request([ ['EmailSubmission/get', { accountId: accountId ?? await this.resolveSubmissionAccountId(submissionId), 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, accountId?: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> { const response = await this.request([ ['EmailSubmission/get', { accountId: accountId ?? await this.resolveSubmissionAccountId(submissionId), 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; } /** * Every account whose submissions this session can see: the primary * submission account plus each shared/group account that advertises the * submission capability in its own right. * * A delayed send composed from a shared identity is created in the *shared* * account (sendEmail passes the target account to getSubmissionAccountId), * so a scheduled-mail lookup that only ever asks the primary account misses * it entirely: the message stays pending on the server and goes out at * sendAt, but the UI cannot list, cancel or reschedule it. See #874. */ private getSubmissionAccountIds(): string[] { const ids: string[] = []; const primary = this.getSubmissionAccountId(); if (primary) ids.push(primary); for (const [accountId, account] of Object.entries(this.session?.accounts ?? {})) { if (ids.includes(accountId)) continue; if (account?.accountCapabilities?.['urn:ietf:params:jmap:submission']) ids.push(accountId); } return ids; } /** * Locate the account that actually holds a submission. Shared-account * submissions are invisible to (and not updatable through) the primary * account, which answers `notFound`. Resolved by asking each submission * account in turn, primary first. */ private async resolveSubmissionAccountId(submissionId: string): Promise { const accountIds = this.getSubmissionAccountIds(); if (accountIds.length <= 1) return accountIds[0] ?? this.getSubmissionAccountId(); for (const accountId of accountIds) { try { const response = await this.request([ ['EmailSubmission/get', { accountId, ids: [submissionId], properties: ['id'] }, '0'], ]); const list = response.methodResponses?.[0]?.[1]?.list as Array<{ id?: string }> | undefined; if (list?.length) return accountId; } catch { // An account that cannot answer for this submission is simply not the // owner; keep looking rather than failing the whole operation. } } return this.getSubmissionAccountId(); } 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; } /** * Whether the submission account advertises an SMTP extension (RFC 8621 * §1.3 `submissionExtensions`), e.g. "DSN" or "REQUIRETLS". */ supportsSubmissionExtension(extension: string, accountId?: string): boolean { return this.hasSubmissionExtension(this.getSubmissionCapability(accountId)?.submissionExtensions, extension); } 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; } 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(accountId?: string): boolean { // Gate on the ACCOUNT capability: a server can advertise contacts while an // account has its jmap-contact-* / dav-card-* permissions revoked. Shared accounts // aren't always advertised per-account, so fall back to the server-wide // capability - accountCapabilities is a subset of it (RFC 8620 s2). const id = accountId || this.accountId; const account = this.accounts[id]; if (!account) return false; if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"]) return true; return !account.isPersonal && !!this.capabilities?.["urn:ietf:params:jmap:contacts"]; } supportsCalendars(accountId?: string): boolean { // Gate on the ACCOUNT capability: a server can advertise calendars while an // account has its jmap-calendar-* / dav-cal-* permissions revoked. Shared accounts // aren't always advertised per-account, so fall back to the server-wide // capability - accountCapabilities is a subset of it (RFC 8620 s2). const id = accountId || this.accountId; const account = this.accounts[id]; if (!account) return false; if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"]) return true; return !account.isPersonal && !!this.capabilities?.["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] === '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:owner")) { 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:owner")) { 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 === false) { 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 === false) { accountIds.push(id); } } return [primaryId, ...accountIds]; } async getAddressBooks(options?: { throwOnError?: boolean }): 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[]; } const methodError = response.methodResponses?.[0]?.[1]; throw new Error(methodError?.description || methodError?.type || "AddressBook/get failed"); } catch (error) { console.error('Failed to get address books:', error); // Callers that would treat an empty list as "nothing exists yet" must be // able to tell a real empty account from a failed fetch (#730). if (options?.throwOnError) throw 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. `isDefault` is deliberately not // among them: it is read-only in AddressBook/set and including it fails the // whole update with invalidProperties - use setDefaultAddressBook instead. const { name, description, sortOrder, 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 (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"); } /** * Mark an address book as the account default. `isDefault` is read-only in * AddressBook/set - the default is changed via the `onSuccessSetIsDefault` * request argument instead (same shape as Calendar/set). */ async setDefaultAddressBook(addressBookId: string, targetAccountId?: string): Promise { const accountId = targetAccountId || this.getContactsAccountId(); const response = await this.request([ ["AddressBook/set", { accountId, onSuccessSetIsDefault: addressBookId, }, "0"] ], this.contactUsing()); 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 address book"); } if (methodName !== "AddressBook/set") { throw new Error("Failed to set default address book"); } } async deleteAddressBook( addressBookId: string, targetAccountId?: string, options?: { removeContents?: boolean }, ): Promise { const accountId = targetAccountId || this.getContactsAccountId(); // Without onDestroyRemoveContents a book that still holds cards is // refused with `addressBookHasContents` (RFC 9610 §2.3). const response = await this.request([ ["AddressBook/set", { accountId, destroy: [addressBookId], ...(options?.removeContents ? { onDestroyRemoveContents: true } : {}), }, "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 []; } } // ── CalendarEventNotification (draft-ietf-jmap-calendars §7) ─── /** * Pending event notifications (invitations, updates, cancellations made by * other participants), oldest first. `event` is requested for the title * only; the default property set omits it. */ async getCalendarEventNotifications(): Promise { if (!this.supportsCalendars()) return []; const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["CalendarEventNotification/query", { accountId, sort: [{ property: "created", isAscending: true }], }, "0"], ["CalendarEventNotification/get", { accountId, "#ids": { resultOf: "0", name: "CalendarEventNotification/query", path: "/ids" }, properties: ["id", "created", "changedBy", "comment", "type", "calendarEventId", "isDraft", "event"], }, "1"], ], this.calendarUsing()); const getResp = response.methodResponses?.find((r) => r[0] === "CalendarEventNotification/get"); if (!getResp) { const error = response.methodResponses?.find((r) => r[0] === "error")?.[1] as { description?: string } | undefined; throw new Error(error?.description || "Failed to load calendar event notifications"); } return ((getResp[1] as { list?: CalendarEventNotification[] }).list ?? []); } /** Acknowledges (destroys) event notifications that have been shown. */ async destroyCalendarEventNotifications(ids: string[]): Promise { if (ids.length === 0 || !this.supportsCalendars()) return; const accountId = this.getCalendarsAccountId(); for (const batch of batched(ids, this.getMaxObjectsInSet())) { await this.request([ ["CalendarEventNotification/set", { accountId, destroy: batch }, "0"], ], this.calendarUsing()); } } // ── ParticipantIdentity (draft-ietf-jmap-calendars §6) ───────── /** * The calendar addresses this user can organise events as, with the * server's default flagged. Empty when the server has no calendars. */ async getParticipantIdentities(): Promise { if (!this.supportsCalendars()) return []; const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["ParticipantIdentity/get", { accountId }, "0"], ], this.calendarUsing()); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== "ParticipantIdentity/get") { throw new Error((result as { description?: string })?.description || "Failed to load participant identities"); } return ((result as { list?: Array & { id: string }> }).list ?? []).map((i) => ({ id: i.id, name: i.name ?? "", calendarAddress: i.calendarAddress ?? "", isDefault: i.isDefault === true, })); } /** Makes `id` the default identity (ParticipantIdentity/set onSuccessSetIsDefault). */ async setDefaultParticipantIdentity(id: string): Promise { const accountId = this.getCalendarsAccountId(); const response = await this.request([ ["ParticipantIdentity/set", { accountId, onSuccessSetIsDefault: id }, "0"], ], this.calendarUsing()); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== "ParticipantIdentity/set") { throw new Error((result as { description?: string })?.description || "Failed to set the default participant identity"); } } // ── Principal/getAvailability (free/busy) ────────────────────── static readonly PRINCIPALS_AVAILABILITY_CAPABILITY = 'urn:ietf:params:jmap:principals:availability'; /** Whether the server can answer free/busy queries for its principals. */ supportsAvailability(): boolean { return this.supportsPrincipals() && this.hasCapability(JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY); } /** * The busy periods of a principal between `utcStart` and `utcEnd` * (Principal/getAvailability). Event details are never requested: this * only tells whether an attendee is free, not what they are doing. */ async getPrincipalAvailability(principalId: string, utcStart: Date, utcEnd: Date): Promise { if (!this.supportsAvailability()) return []; const response = await this.request([ ["Principal/getAvailability", { accountId: this.accountId, id: principalId, utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'), utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'), showDetails: false, }, "0"], ], [...this.principalsUsing(), JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY]); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== "Principal/getAvailability") { throw new Error((result as { description?: string })?.description || "Failed to query availability"); } return ((result as { list?: Array<{ utcStart: string; utcEnd: string; busyStatus?: BusyPeriod['busyStatus'] }> }).list ?? []) .map((p) => ({ utcStart: p.utcStart, utcEnd: p.utcEnd, busyStatus: p.busyStatus ?? null })); } // ── ShareNotification (RFC 9670 §3) ──────────────────────────── /** Whether the server can report who shared what with this user. */ supportsShareNotifications(): boolean { return this.supportsPrincipals(); } /** * Every pending share notification of the user's own account, oldest * first. The list is small by construction: notifications are destroyed * once they have been shown (see destroyShareNotifications). */ async getShareNotifications(): Promise { if (!this.supportsShareNotifications()) return []; const accountId = this.accountId; const response = await this.request([ ["ShareNotification/query", { accountId, sort: [{ property: "created", isAscending: true }], }, "0"], ["ShareNotification/get", { accountId, "#ids": { resultOf: "0", name: "ShareNotification/query", path: "/ids" }, }, "1"], ], this.principalsUsing()); const getResp = response.methodResponses?.find((r) => r[0] === "ShareNotification/get"); if (!getResp) { const error = response.methodResponses?.find((r) => r[0] === "error")?.[1] as { description?: string } | undefined; throw new Error(error?.description || "Failed to load share notifications"); } return ((getResp[1] as { list?: ShareNotification[] }).list ?? []); } /** Acknowledges (destroys) share notifications that have been shown. */ async destroyShareNotifications(ids: string[]): Promise { if (ids.length === 0 || !this.supportsShareNotifications()) return; for (const batch of batched(ids, this.getMaxObjectsInSet())) { await this.request([ ["ShareNotification/set", { accountId: this.accountId, destroy: batch }, "0"], ], this.principalsUsing()); } } /** * 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"); } } // ── Mailbox sharing (urn:ietf:params:jmap:mail:share) ───────────── static readonly MAIL_SHARE_CAPABILITY = 'urn:ietf:params:jmap:mail:share'; /** Whether the account's server accepts `shareWith` on Mailbox objects. */ supportsMailboxSharing(accountId?: string): boolean { return this.hasAccountCapability(JMAPClient.MAIL_SHARE_CAPABILITY, accountId || this.accountId); } /** * The current `shareWith` map of one folder. Fetched on demand (the share * dialog) rather than with every folder list: Stalwart omits shareWith from * Mailbox/get unless it is requested, and it is only needed here. */ async getMailboxShareWith(mailboxId: string, accountId?: string): Promise | null> { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Mailbox/get", { accountId: targetAccountId, ids: [mailboxId], properties: ["id", "shareWith"], }, "0"], ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY]); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== "Mailbox/get") { throw new Error((result as { description?: string })?.description || "Failed to load folder sharing"); } const mailbox = ((result as { list?: Array<{ id: string; shareWith?: Record | null }> }).list ?? []) .find((mb) => mb.id === mailboxId); if (!mailbox) { throw new Error("Folder not found"); } return mailbox.shareWith ?? null; } /** * Grants (`rights`) or revokes (`null`) a principal's access to a folder * via a `shareWith/{principalId}` patch, like the calendar / address book * variants. */ async setMailboxShare( mailboxId: string, principalId: string, rights: MailboxRights | null, accountId?: string, ): Promise { const targetAccountId = accountId || this.accountId; const response = await this.request([ ["Mailbox/set", { accountId: targetAccountId, update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } }, }, "0"], ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY]); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== "Mailbox/set") { throw new Error((result as { description?: string })?.description || "Failed to update folder share"); } const setResult = result as { updated?: Record; notUpdated?: Record }; if (setResult.notUpdated?.[mailboxId]) { throw new Error(setResult.notUpdated[mailboxId].description || "Failed to update folder share"); } if (!setResult.updated || !(mailboxId in setResult.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.map(contactFromWire)); } } return allContacts; } async getContacts(addressBookId?: string, options?: { throwOnError?: boolean }): 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); if (options?.throwOnError) throw 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 || []) as ContactCard[]; return list[0] ? contactFromWire(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 }; } } const contactData = contactToWire(contact, 'create'); const response = await this.request([ ["ContactCard/set", { accountId, create: { "new-contact": { ...contactData, // Stalwart stores the card without one if omitted (#644) uid: contact.uid || `urn:uuid:${generateUUID()}`, 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(); const cleanUpdates = contactToWire(updates, 'update'); 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(contactFromWire).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 []; } } /** * Calendar/get for one account. The session's first one on the primary * calendar account also carries the synthetic-id probe (see * supportsSyntheticCalendarIds), so the range query that follows needs no * round trip of its own for it. */ private requestCalendars(accountId: string): Promise { const methodCalls: JMAPMethodCall[] = [ ["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"], ]; const carriesProbe = !this.syntheticCalendarIdSupport && accountId === this.getCalendarsAccountId(); if (carriesProbe) methodCalls.push(this.syntheticIdProbeCall(accountId)); const pending = this.request(methodCalls, this.calendarUsing()); if (carriesProbe) this.rememberSyntheticIdProbe(pending); return pending; } async getCalendars(): Promise { try { const accountId = this.getCalendarsAccountId(); const response = await this.requestCalendars(accountId); if (response.methodResponses?.[0]?.[0] === "Calendar/get") { const calendars = (response.methodResponses[0][1].list || []) as Calendar[]; const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, calendars.map((c) => c.id)); return calendars.map((cal) => ({ ...cal, isTasksOnly: tasksOnly.has(cal.id) })); } return []; } catch (error) { console.error('Failed to get calendars:', error); return []; } } /** * Ids of the given calendars that hold only tasks and no events, so the event * calendar UI can hide them. Stalwart's Calendar/get exposes no * supported-component set, so this is derived by scanning the objects. * * Cheap in the common case and safe on the edges: each page is classified as * it arrives and, as soon as EVERY calendar has been seen to contain an event, * the scan stops (a normal event account exits after one page). It only marks * a calendar tasks-only after scanning ALL of that account's objects; if the * account has more than the scan cap, it fails OPEN (marks nothing) rather * than risk hiding a calendar whose events sit past the cap. Any error also * fails open. Empty calendars are never marked (they must stay visible). */ private async getTasksOnlyCalendarIds(accountId: string, rawCalendarIds: string[]): Promise> { if (rawCalendarIds.length === 0) return new Set(); const QUERY_PAGE = 500; const MAX_SCAN = 5000; // safety bound; beyond this we fail open const PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds']; const timeZone = getUserTimeZone(); const objects: ScannedCalendarObject[] = []; const remaining = new Set(rawCalendarIds); // calendars not yet known to hold an event try { let exhausted = false; for (let position = 0; position < MAX_SCAN;) { const queryArgs: Record = { accountId, limit: QUERY_PAGE, position }; if (timeZone) queryArgs.timeZone = timeZone; const qResp = await this.request([["CalendarEvent/query", queryArgs, "0"]], this.calendarUsing()); if (qResp.methodResponses?.[0]?.[0] === "error") return new Set(); // fail open const pageIds: string[] = qResp.methodResponses?.[0]?.[1]?.ids || []; if (pageIds.length === 0) { exhausted = true; break; } const getResp = await this.request([ ["CalendarEvent/get", { accountId, ids: pageIds, properties: PROPERTIES, ...(timeZone ? { timeZone } : {}) }, "0"] ], this.calendarUsing()); const list = (getResp.methodResponses?.[0]?.[1]?.list || []) as ScannedCalendarObject[]; for (const obj of list) { objects.push(obj); if (!isTaskLikeObject(obj) && obj.calendarIds) { for (const id of Object.keys(obj.calendarIds)) remaining.delete(id); } } // Every calendar has at least one event -> none can be tasks-only. if (remaining.size === 0) return new Set(); if (pageIds.length < QUERY_PAGE) { exhausted = true; break; } position += pageIds.length; } if (!exhausted) return new Set(); // hit the cap without finishing -> hide nothing return findTasksOnlyCalendarIds(objects, rawCalendarIds); } catch { return new Set(); // fail open } } 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.requestCalendars(accountId); if (response.methodResponses?.[0]?.[0] === "Calendar/get") { const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[]; const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, rawCalendars.map((c) => c.id)); 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, isTasksOnly: tasksOnly.has(cal.id), })); 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, options?: CreateCalendarOptions): Promise { const accountId = targetAccountId || this.getCalendarsAccountId(); // Stalwart's Calendar/set cannot express the CalDAV // supported-calendar-component-set, and the server only accepts that // property while the collection is being created, so calendars in the // user's own account are made with MKCALENDAR through the WebDAV proxy and // finished off over JMAP. If that path is unavailable (older server, proxy // unreachable, shared account) we fall back to a plain Calendar/set, which // yields the previous "every component type" calendar (#760). if (accountId === this.getAccountId()) { const created = await this.createCalendarViaCalDav(calendar, accountId, options?.components); if (created) return created; } const response = await this.request([ ["Calendar/set", { accountId, create: { "new-calendar": calendar } }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/set") { const result = response.methodResponses[0][1]; if (result.notCreated?.["new-calendar"]) { const error = result.notCreated["new-calendar"]; throw new Error(error.description || "Failed to create calendar"); } const createdId = result.created?.["new-calendar"]?.id; if (createdId) { const created = await this.getCalendarById(accountId, createdId); if (created) return created; } } throw new Error("Failed to create calendar"); } private async getCalendarById(accountId: string, calendarId: string): Promise { const response = await this.request([ ["Calendar/get", { accountId, ids: [calendarId], properties: CALENDAR_PROPERTIES }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] === "Calendar/get") { const list = response.methodResponses[0][1].list || []; if (list[0]) return list[0] as Calendar; } return null; } private async listAccountCalendars(accountId: string, properties: readonly string[]): Promise { const response = await this.request([ ["Calendar/get", { accountId, ids: null, properties }, "0"] ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] !== "Calendar/get") { throw new Error("Failed to list calendars"); } return (response.methodResponses[0][1].list || []) as Calendar[]; } /** * MKCALENDAR the collection (pinning its component set), find the JMAP id * Stalwart gave it, then apply the remaining properties with Calendar/set. * Returns null when the CalDAV step is unavailable so the caller can fall * back to a JMAP-only create; throws if the collection was created but * could not be completed (a retry over JMAP would leave a duplicate). */ private async createCalendarViaCalDav( calendar: Partial, accountId: string, components?: CalendarComponentType[], ): Promise { const displayName = typeof calendar.name === 'string' ? calendar.name.trim() : ''; if (!displayName) return null; const componentSet = components && components.length > 0 ? Array.from(new Set(components)) : DEFAULT_CALENDAR_COMPONENTS; // The DAV response carries no JMAP id, so snapshot the ids beforehand and // pick out the newcomer afterwards. let before: Set; try { before = new Set((await this.listAccountCalendars(accountId, ["id"])).map((c) => c.id)); } catch { return null; } let created: boolean; try { created = await mkCalendarCollection({ collectionName: newCalendarCollectionName(), displayName, components: componentSet, }); } catch (error) { debug.log('calendar', 'MKCALENDAR unavailable, falling back to Calendar/set', error); return null; } if (!created) return null; const fresh = (await this.listAccountCalendars(accountId, CALENDAR_PROPERTIES)).filter((c) => !before.has(c.id)); const found = fresh.find((c) => c.name === displayName) ?? fresh[0]; if (!found) throw new Error("Calendar was created but could not be found"); // The name went in with MKCALENDAR; colour, visibility etc. go over JMAP. const updates: Record = {}; for (const [key, value] of Object.entries(calendar as Record)) { if (key !== 'name' && value !== undefined && CALENDAR_SETTABLE_PROPERTIES.has(key)) { updates[key] = value; } } if (Object.keys(updates).length === 0) return found; await this.updateCalendar(found.id, updates as Partial, accountId); return (await this.getCalendarById(accountId, found.id)) ?? { ...found, ...updates } as 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(); // Page through the query to collect ALL ids. Callers (import UID // deduplication, clear-calendar, subscription refresh) rely on this being // the complete list; a single capped query silently hid everything past // the first page in large accounts (#113: spurious "UID already exists" // import failures once an account holds >1000 events). const QUERY_PAGE = 1000; const MAX_IDS = 50000; // safety bound 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 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 pageIds: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || []; ids.push(...pageIds); if (pageIds.length < QUERY_PAGE) break; position += pageIds.length; } 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(); // Result of the one-off synthetic-id capability probe, shared by every // range query on this client (see supportsSyntheticCalendarIds). private syntheticCalendarIdSupport: Promise | null = null; /** * Whether CalendarEvent/set accepts the synthetic ids that * CalendarEvent/query?expandRecurrences=true hands out (Stalwart >= 0.16.20). * * Probed once per client with a side-effect-free update of a synthetic id * whose event cannot exist (see SYNTHETIC_ID_PROBE): a server that still * rejects synthetic ids answers `invalidProperties` before looking the * event up, one that supports them answers `notFound`. Anything else * (transport error, non-Stalwart server) counts as unsupported so the * client-side expansion keeps being used. The session's first Calendar/get * carries the probe along (getCalendars), so the usual calendar start-up * pays no extra round trip; a range query that comes first probes itself. */ async supportsSyntheticCalendarIds(): Promise { if (!this.syntheticCalendarIdSupport) { const accountId = this.getCalendarsAccountId(); this.rememberSyntheticIdProbe( this.request([this.syntheticIdProbeCall(accountId)], this.calendarUsing()), ); } return this.syntheticCalendarIdSupport!; } private static readonly SYNTHETIC_ID_PROBE_CALL_ID = 'synthetic-id-probe'; private syntheticIdProbeCall(accountId: string): JMAPMethodCall { return ["CalendarEvent/set", { accountId, update: { [SYNTHETIC_ID_PROBE]: {} } }, JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID]; } /** Derive (and cache) the probe verdict from a response that carried the probe call. */ private rememberSyntheticIdProbe(pending: Promise): void { this.syntheticCalendarIdSupport = pending.then( (response) => { const entry = response.methodResponses?.find((r) => r[2] === JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID); if (!entry || entry[0] !== "CalendarEvent/set") return false; const failure = (entry[1] as { notUpdated?: Record } | undefined)?.notUpdated?.[SYNTHETIC_ID_PROBE]; const supported = failure?.type === 'notFound'; debug.log('calendar', 'Synthetic calendar id probe', { supported, failure: failure ?? null }); return supported; }, (error) => { debug.log('calendar', 'Synthetic calendar id probe failed; using client-side recurrence expansion', error); return false; }, ); } async queryAllCalendarEvents( filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number ): Promise { try { const allEvents: CalendarEvent[] = []; 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 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: { ...filter, ...(filter.after ? { after: toLocalDateTime(filter.after, timeZone) } : {}), ...(filter.before ? { before: toLocalDateTime(filter.before, timeZone) } : {}), }, }; // Interpret the LocalDateTime after/before filter values in the user's // time zone (Stalwart defaults to UTC, shifting range boundaries). // UTC bounds are converted to that wall clock above, because Stalwart // drops a `Z` or offset rather than honouring it. if (timeZone) { queryArgs.timeZone = timeZone; } // Let the server expand recurring series into one synthetic id per // occurrence when it also accepts those ids in CalendarEvent/set // (Stalwart >= 0.16.20; the server needs both range bounds for it). // Older servers keep the client-side expansion in // lib/recurrence-expansion.ts, which runs on whatever comes back. let expandRecurrences = Boolean(filter.after && filter.before) && await this.supportsSyntheticCalendarIds(); if (expandRecurrences) { queryArgs.expandRecurrences = true; } if (sort) { queryArgs.sort = sort; } const GET_BATCH_SIZE = this.getMaxObjectsInGet(); // Without an explicit limit, page through the whole range: a busy // calendar (or a long expanded range) holds more than one page. const pageSize = limit || 1000; const maxIds = limit || 20000; // First, query to get IDs const ids: string[] = []; for (let position = 0; ids.length < maxIds;) { const queryResponse = await this.request([ ["CalendarEvent/query", { ...queryArgs, limit: pageSize, position }, "0"], ], this.calendarUsing()); const [name, result] = queryResponse.methodResponses?.[0] ?? []; if (name === "error") { // Stalwart fails the whole query once a range expands to more than // maxRecurrenceExpansions occurrences. Ask for the series instead; // lib/recurrence-expansion.ts expands them on the client. if (expandRecurrences && result?.type === "invalidArguments" && /expanded recurrences exceeds/i.test(result?.description ?? "")) { debug.log('calendar', 'Range expands past the server limit; expanding on the client instead'); expandRecurrences = false; delete queryArgs.expandRecurrences; ids.length = 0; position = 0; continue; } // 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(result?.description || result?.type || "CalendarEvent/query failed"), { jmapErrorType: result?.type }, ); } const page: string[] = result?.ids || []; ids.push(...page); if (limit || page.length < pageSize) break; position += page.length; } 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 normalized = allEvents .filter((event) => !isTaskObject(event)) .map((event) => normalizeCalendarEventLike(event)); const filtered = expandRecurrences ? await this.hydrateExpandedOccurrences(normalized, accountId, timeZone) : normalized; 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 []; } } /** * Server-expanded occurrences come back without their series' recurrence * rule / overrides (and all-day ones without showWithoutTime). Fetch those * from the base events in one batched get and merge them in, so an * occurrence carries the same series context the client-side expansion * used to copy from its master. */ private async hydrateExpandedOccurrences( events: CalendarEvent[], accountId: string, timeZone?: string, ): Promise { const baseIds = Array.from(new Set( events .filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId) .map((event) => event.baseEventId as string), )); if (baseIds.length === 0) return events; const bases = new Map>(); for (const batch of batched(baseIds, this.getMaxObjectsInGet())) { const response = await this.request([ ["CalendarEvent/get", { accountId, properties: [...RECURRENCE_BASE_PROPERTIES], ids: batch, ...(timeZone ? { timeZone } : {}), }, "0"], ], this.calendarUsing()); if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") continue; for (const base of (response.methodResponses[0][1].list || []) as Partial[]) { if (base.id) bases.set(base.id, normalizeCalendarEventLike(base)); } } return hydrateRecurrenceInstances(events, bases); } 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, baseEventId: _be, 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[]; notCreated: Record }> { if (events.length === 0) return { created: [], failed: [], notCreated: {} }; const accountId = targetAccountId || this.getCalendarsAccountId(); debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId }); const createdIds: string[] = []; const failed: string[] = []; const notCreated: Record = {}; 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()); // A method-level error (unknown calendar, missing capability, …) is // not a per-event failure: nothing was created, say so. (#434) this.throwOnCalendarSetError(response, 'create calendar events'); 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); notCreated[key] = result.notCreated[key]; } } } } if (createdIds.length === 0) { return { created: [], failed, notCreated }; } // 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, notCreated }; } 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, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, progressUpdated: _pu, ...cleanUpdates } = updates as CalendarEvent & { progressUpdated?: string | null }; 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]; debug.log('calendar', '[CalendarEvent/parse] raw result:', result); 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"); } /** * Reject a CalendarEvent/set response that came back as a method-level * error. Without this the callers counted the (empty) results and * reported "0 events cleared" with no error at all. (#434) */ private throwOnCalendarSetError(response: JMAPResponse, action: string): void { const [name, result] = response.methodResponses?.[0] ?? []; if (name === "error") { throw new Error(`Failed to ${action}: ${result?.description || result?.type || 'unknown error'}`); } } /** * Destroy events in batches. Per-id failures come back in `notDestroyed` * (with the server's type/description) so the caller can report them; * a method-level error, or a batch in which nothing at all could be * destroyed, rejects with the first description. (#434) */ async batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: Record }> { if (eventIds.length === 0) return { destroyed: [], notDestroyed: {} }; const accountId = targetAccountId || this.getCalendarsAccountId(); const destroyed: string[] = []; const notDestroyed: Record = {}; for (const batch of batched(eventIds, this.getMaxObjectsInSet())) { const response = await this.request([ ["CalendarEvent/set", { accountId, destroy: batch }, "0"] ], this.calendarUsing()); this.throwOnCalendarSetError(response, 'delete calendar events'); let destroyedInBatch = 0; if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") { const result = response.methodResponses[0][1]; if (result.destroyed) { destroyed.push(...result.destroyed); destroyedInBatch = result.destroyed.length; } if (result.notDestroyed) Object.assign(notDestroyed, result.notDestroyed); } if (destroyedInBatch === 0 && batch.length > 0) { const first = Object.values(notDestroyed)[0]; throw new Error( `Failed to delete calendar events: ${first?.description || first?.type || 'server refused the request'}`, ); } } 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(normalizeCalendarTask({ ...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, progressUpdated: _pu, ...taskData } = task as Partial & { progressUpdated?: string | null }; 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 = normalizeCalendarTask({ ...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"); } // RFC 9670 places principals:owner in accountCapabilities rather than // the session capabilities, so this only fires on servers that also // advertise it in the session. if (this.hasCapability("urn:ietf:params:jmap:principals:owner")) { 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[]).map(fromWireFileNode); } 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 on older 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 starts from `FileNode/get` with `ids: null` (return-all), * NOT `FileNode/query`. Before Stalwart 0.16.6, 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 nodes = await this.fetchAllFileNodes(this.getFilesAccountId()); return nodes.map(fromWireFileNode); } /** * Every raw FileNode of one account, past the server's /get ceiling. * * `FileNode/get { ids: null }` stops at maxObjectsInGet (500 by default) and * Stalwart gives no sign that the list was cut, so larger accounts silently * lost files (#1069). A full first page therefore counts as truncated: the * remaining ids come from paging FileNode/query and are fetched in /get-sized * batches. Stalwart before 0.16.6 leaves folders out of FileNode/query, so * parents that are still unknown afterwards are fetched by id. */ private async fetchAllFileNodes(accountId: string): Promise { const using = this.fileUsing(); const properties = JMAPClient.FILE_NODE_PROPERTIES; const response = await this.request( [["FileNode/get", { accountId, ids: null, properties }, "fng0"]], using, ); 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"); } const firstPage = (getResult[1].list || []) as FileNode[]; const maxObjects = this.getMaxObjectsInGet(); if (firstPage.length < maxObjects) return firstPage; const known = new Map(firstPage.map(node => [node.id, node])); // Several /get calls share one request, so a large account costs a few // round trips rather than one per batch - the tree is re-read on every // navigation. const fetchByIds = async (ids: string[]) => { const calls = batched(ids, maxObjects).map((batch, i): JMAPMethodCall => ["FileNode/get", { accountId, ids: batch, properties }, `fng${i + 1}`]); for (const group of batched(calls, this.getMaxCallsInRequest())) { const batchResponse = await this.request(group, using); for (const result of batchResponse.methodResponses || []) { if (result[0] !== "FileNode/get") { throw new Error(result[1]?.description || "FileNode/get failed"); } for (const node of (result[1].list || []) as FileNode[]) known.set(node.id, node); } } }; try { const missing = new Set(); for (let position = 0; position < FILE_NODE_MAX_IDS;) { const queryResponse = await this.request( [["FileNode/query", { accountId, filter: {}, position, limit: FILE_NODE_QUERY_PAGE, calculateTotal: true }, "fnq0"]], using, ); const queryResult = queryResponse.methodResponses?.[0]; if (!queryResult || queryResult[0] !== "FileNode/query") { throw new Error(queryResult?.[1]?.description || "FileNode/query failed"); } // The server may clamp `limit`, so only an empty page or a reached // total ends the listing - never a page shorter than the one asked for. const pageIds = (queryResult[1].ids || []) as string[]; if (pageIds.length === 0) break; for (const id of pageIds) { if (!known.has(id)) missing.add(id); } position += pageIds.length; const total = queryResult[1].total; if (typeof total === "number" && position >= total) break; } await fetchByIds([...missing]); const asked = new Set(); for (;;) { const parents = new Set(); for (const node of known.values()) { if (node.parentId && !known.has(node.parentId) && !asked.has(node.parentId)) parents.add(node.parentId); } if (parents.size === 0) break; for (const id of parents) asked.add(id); await fetchByIds([...parents]); } } catch (error) { // A partial tree beats none: keep what was read, as before #1069. console.warn(`[Files] Listing for account ${accountId} is incomplete past ${maxObjects} nodes:`, error); } return [...known.values()]; } /** * 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 nodes = await this.fetchAllFileNodes(accountId); for (const node of nodes) { all.push({ ...fromWireFileNode(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 node = targetAccountId ? { accountId: targetAccountId, id: fileNodeId } : this.resolveFileNodeId(fileNodeId); const accountId = node.accountId; const rawId = node.id as string; // `forbiddenNameChars` arrived with the draft (Stalwart 0.16.6) that split // mayWrite into the finer rights; older servers reject those names. const fileCapability = this.session?.accounts?.[accountId]?.accountCapabilities?.["urn:ietf:params:jmap:filenode"] as Record | undefined; const legacyRights = !!fileCapability && !("forbiddenNameChars" in fileCapability); const wireRights = rights && legacyRights ? toLegacyRights(rights) : rights; const response = await this.request([ ["FileNode/set", { accountId, update: { [rawId]: { [`shareWith/${principalId}`]: wireRights } }, }, "0"], ], this.fileUsing()); const result = response.methodResponses?.[0]?.[1]; if (result?.notUpdated?.[rawId]) { const err = result.notUpdated[rawId]; throw new Error(err.description || "Failed to update file share"); } if (!result?.updated || !(rawId in result.updated)) { throw new Error("Server did not confirm the share update"); } } /** * The account and bare id behind a FileNode id. Nodes of shared drives are * listed as ":" (listAllFileNodesAcrossAccounts; JMAP ids * never contain ':'), and a write must go to that account with the bare id. * `null` is the primary account's root. */ private resolveFileNodeId(id: string | null): { accountId: string; id: string | null } { const primary = this.getFilesAccountId(); if (id === null) return { accountId: primary, id: null }; const sep = id.indexOf(':'); if (sep > 0) { const accountId = id.slice(0, sep); if (accountId !== primary && this.getFilesCapableAccountIds().includes(accountId)) { return { accountId, id: id.slice(sep + 1) }; } } return { accountId: primary, id }; } /** Namespace a node created in `accountId` the way the listings do. */ private namespaceFileNode(node: FileNode, accountId: string): FileNode { if (accountId === this.getFilesAccountId()) return node; return { ...node, id: `${accountId}:${node.id}`, parentId: node.parentId == null ? null : `${accountId}:${node.parentId}`, }; } /** * Resolve a parent for a node that lives in `accountId`. Folders only hold * nodes of their own account, so a parent in another account is refused * instead of being sent (the server would read it as a local id). */ private parentInAccount(parentId: string | null, accountId: string): string | null { const parent = this.resolveFileNodeId(parentId); if (parent.accountId !== accountId) { throw new Error('Files cannot be moved between accounts'); } return parent.id; } /** * FileNode/set `create` of one node. `onExists: "rename"` (Stalwart 0.16.6+, * ignored before) turns a name clash into "name (2)" instead of a failure. */ private async createFileNodeIn( accountId: string, props: Record, what: string, ): Promise { const baseName = String(props.name ?? ''); for (let attempt = 1; ; attempt++) { const name = attempt === 1 ? baseName : numberedFileName(baseName, attempt); const response = await this.request( [["FileNode/set", { accountId, onExists: "rename", create: { n0: { ...props, name } } }, "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?.n0; if (created) { // The create response carries only server-set properties; keep the // ones sent so callers can rely on name/parentId/type. return withDecodedName({ ...props, name, ...created } as FileNode); } const err = result[1].notCreated?.n0; // Servers before 0.16.6 ignore onExists and refuse the clash instead. if (attempt < 20 && /already exists/i.test(err?.description ?? '')) continue; throw new Error(err?.description || `Failed to create ${what}`); } } async createFileDirectory(name: string, parentId: string | null): Promise { const parent = this.resolveFileNodeId(parentId); // 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 (parent.id !== null) { dirProps.parentId = parent.id; } const created = await this.createFileNodeIn(parent.accountId, dirProps, 'directory'); return this.namespaceFileNode(created, parent.accountId); } async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise { const parent = this.resolveFileNodeId(parentId); //fall back for long MIME types const safeType = type.length > 30 ? 'application/octet-stream' : type; const fileProps: Record = { name, type: safeType, blobId, size }; if (parent.id !== null) { fileProps.parentId = parent.id; } const created = await this.createFileNodeIn(parent.accountId, fileProps, 'file node'); return this.namespaceFileNode(created, parent.accountId); } async updateFileNode(id: string, updates: Partial>): Promise { const node = this.resolveFileNodeId(id); const patch: Record = { ...updates }; if ('parentId' in updates) { patch.parentId = this.parentInAccount(updates.parentId ?? null, node.accountId); } const response = await this.request( [["FileNode/set", { accountId: node.accountId, update: { [node.id as string]: patch }, }, "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?.[node.id as string]) { throw new Error(result[1].notUpdated[node.id as string].description || "Failed to update file node"); } } /** Group FileNode ids by the account that owns them. */ private groupFileNodeIds(entries: Array<[string, T]>): Map> { const groups = new Map>(); for (const [id, value] of entries) { const node = this.resolveFileNodeId(id); const list = groups.get(node.accountId) ?? []; list.push({ id, rawId: node.id as string, value }); groups.set(node.accountId, list); } return groups; } /** * 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 updated: string[] = []; const notUpdated: Record = {}; for (const [accountId, nodes] of this.groupFileNodeIds(entries)) { for (const batch of batched(nodes, this.getMaxObjectsInSet())) { const byRawId = new Map(batch.map(n => [n.rawId, n.id])); const update: Record> = {}; for (const n of batch) { const patch: Record = { ...n.value }; if ('parentId' in n.value) { try { patch.parentId = this.parentInAccount(n.value.parentId ?? null, accountId); } catch (error) { notUpdated[n.id] = error instanceof Error ? error.message : 'not updated'; continue; } } update[n.rawId] = patch; } if (Object.keys(update).length === 0) continue; const response = await this.request( [["FileNode/set", { accountId, update }, "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 rawId of Object.keys(notUpdatedMap)) { notUpdated[byRawId.get(rawId) ?? rawId] = notUpdatedMap[rawId]?.description || 'not updated'; } // Servers may omit the `updated` map; treat anything not rejected as updated. const done = Object.keys(updatedMap).length > 0 ? Object.keys(updatedMap).map(rawId => byRawId.get(rawId) ?? rawId) : Object.keys(update).map(rawId => byRawId.get(rawId) as string).filter(id => !(id in notUpdated)); updated.push(...done); } } return { updated, notUpdated }; } async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { const destroyed: string[] = []; for (const [accountId, nodes] of this.groupFileNodeIds(ids.map(id => [id, null] as [string, null]))) { for (const batch of batched(nodes, this.getMaxObjectsInSet())) { const byRawId = new Map(batch.map(n => [n.rawId, n.id])); const response = await this.request( [["FileNode/set", { accountId, destroy: batch.map(n => n.rawId), 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 || []) as string[]).map(rawId => byRawId.get(rawId) ?? rawId)); } } return { destroyed, notDestroyed: [] }; } /** * Copy a file, or a folder with everything in it, into `parentId` (which * may be in another account). A folder node carries no content, so creating * one "copy" node would only make an empty folder: the subtree is recreated * level by level. Across accounts the file contents go through Blob/copy. */ async copyFileNode(id: string, newName: string, parentId: string | null): Promise { const source = this.resolveFileNodeId(id); const target = this.resolveFileNodeId(parentId); const tree = await this.fetchAllFileNodes(source.accountId); const original = tree.find(n => n.id === source.id); if (!original) throw new Error('File node not found'); const isFolderNode = (n: FileNode) => n.blobId == null; const copyBlob = async (blobId: string): Promise => { if (source.accountId === target.accountId) return blobId; const response = await this.request([ ["Blob/copy", { fromAccountId: source.accountId, accountId: target.accountId, blobIds: [blobId] }, "bc0"], ], this.fileUsing()); const result = response.methodResponses?.[0]; const copied = result?.[0] === "Blob/copy" ? result[1].copied?.[blobId] : undefined; if (!copied) { throw new Error(result?.[1]?.notCopied?.[blobId]?.description || result?.[1]?.description || "Failed to copy file content"); } return copied as string; }; const copyInto = async (node: FileNode, name: string, parent: string | null): Promise => { const props: Record = { name }; if (parent !== null) props.parentId = parent; if (!isFolderNode(node)) { props.type = node.type; props.blobId = await copyBlob(node.blobId as string); props.size = node.size; } const created = await this.createFileNodeIn(target.accountId, props, 'copy'); if (isFolderNode(node)) { for (const child of tree.filter(n => n.parentId === node.id)) { await copyInto(child, child.name, created.id); } } return created; }; const created = await copyInto(withDecodedName(original), newName, target.id); return this.namespaceFileNode(created, target.accountId); } 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; // Guards a state-change poll from stacking: the 3s interval, the secondary // poll, and the visibility/online handlers all call checkForStateChanges, so // under socket pressure they would otherwise pile identical POSTs onto an // already-saturated pool and amplify the queueing (#781). private stateCheckInFlight: boolean = false; private visibilityHandler: (() => void) | null = null; private onlineHandler: (() => void) | null = null; // Per-tab SSE budget (#702). Every logged-in account is its own JMAPClient // and each one holds its push stream open as a long-lived fetch - one pinned // socket per account. Browsers allow only 6 HTTP/1.1 connections per host, // so from the sixth login on there is no socket left for an ordinary JMAP // POST: sends grey out and never complete, moves and loads queue for // minutes, all until one of the streams happens to drop. Cap the number of // concurrent streams per tab well below that limit (two tabs of the app // share the same pool) and let the remaining logins fall back to the slow // state poll, which borrows a socket for a moment instead of pinning it. // Waiters are promoted in order as holders release their slot. static readonly MAX_SSE_STREAMS = 2; private static sseHolders = new Set(); private static sseWaiters: JMAPClient[] = []; private static ssePromotionQueued = false; // True between connectSSE and closePushNotifications: this client wants a // live stream and may be promoted into one when a slot frees up. private sseWanted = false; private static acquireSSESlot(client: JMAPClient): boolean { if (JMAPClient.sseHolders.has(client)) return true; if (JMAPClient.sseHolders.size >= JMAPClient.MAX_SSE_STREAMS) return false; JMAPClient.sseHolders.add(client); JMAPClient.sseWaiters = JMAPClient.sseWaiters.filter((c) => c !== client); return true; } private static releaseSSESlot(client: JMAPClient): void { JMAPClient.sseWaiters = JMAPClient.sseWaiters.filter((c) => c !== client); if (!JMAPClient.sseHolders.delete(client)) return; // Promote on a microtask, not inline: the push effect tears down every // client and immediately sets them all up again (account switch), and a // synchronous promotion would open a stream for a waiter that is about to // be closed a few statements later. if (JMAPClient.ssePromotionQueued) return; JMAPClient.ssePromotionQueued = true; queueMicrotask(() => { JMAPClient.ssePromotionQueued = false; JMAPClient.promoteSSEWaiters(); }); } private static enqueueSSEWaiter(client: JMAPClient): void { if (!JMAPClient.sseWaiters.includes(client)) JMAPClient.sseWaiters.push(client); } private static promoteSSEWaiters(): void { while (JMAPClient.sseHolders.size < JMAPClient.MAX_SSE_STREAMS && JMAPClient.sseWaiters.length > 0) { const next = JMAPClient.sseWaiters.shift()!; if (!next.sseWanted || next.intentionallyDisconnected) continue; const url = next.getEventSourceUrl(); if (!url) continue; // Leave the slow poll running only if it is still needed for shared // accounts; the SSE branch of setup re-creates it in that case. if (next.secondaryPollInterval) { clearInterval(next.secondaryPollInterval); next.secondaryPollInterval = null; } next.connectSSE(url); next.startSecondaryAccountPoll(); } } /** Number of SSE streams currently held open by clients in this tab. */ static activeSSEStreamCount(): number { return JMAPClient.sseHolders.size; } /** Whether this client holds one of the tab's SSE slots (vs. slow-polling). */ hasSSEStream(): boolean { return JMAPClient.sseHolders.has(this); } private static readonly STATE_TYPE_MAP: Record = { 'Mailbox/get': 'Mailbox', 'Email/get': 'Email', 'Calendar/get': 'Calendar', 'CalendarEvent/get': 'CalendarEvent', 'SieveScript/get': 'SieveScript', }; 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 setupPushNotifications(): boolean { 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; } /** * 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; this.startSlowPoll(); } /** * The slow (20s) state poll over every account this login can see. Used * alongside SSE for shared accounts, and on its own for logins that did not * get one of the tab's SSE slots (#702) - it covers the primary account too. */ private startSlowPoll(): void { if (this.secondaryPollInterval) 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 { this.sseWanted = true; if (this.isRateLimited()) { this.scheduleSSEReconnect(); return; } // Budget check (#702): with no slot left in this tab, keep this login on // the slow poll and queue it for promotion once a holder releases. It // must not pin a sixth socket and starve every other request. if (!JMAPClient.acquireSSESlot(this)) { JMAPClient.enqueueSSEWaiter(this); this.startSlowPoll(); return; } const url = templateUrl .replace('{types}', '*') .replace('{closeafter}', 'no') .replace('{ping}', '30'); // Already-active guard (#781): never stack a second SSE stream on a live // one. readSSEStream only unwinds on abort or stream-end, so a previous // connect left open would hold its HTTP/1.1 socket indefinitely; with // several accounts each pinning a socket, routine JMAP POSTs then queue and // time out. Abort any prior stream (and its ping monitor / pending // reconnect) before opening the replacement. The old read loop unwinds via // AbortError and, because we install a fresh controller below, its // end-of-stream reconnect guard (`sseAbortController === controller`) no // longer matches, so it does not spawn a competing connection. if (this.sseAbortController) { this.sseAbortController.abort(); this.sseAbortController = null; } this.stopSSEPingMonitor(); if (this.sseReconnectTimeout) { clearTimeout(this.sseReconnectTimeout); this.sseReconnectTimeout = null; } // 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; // No stream is held any more - hand the slot to a waiting login. JMAPClient.releaseSSESlot(this); if (!this.pollingInterval) { this.startPollingFallback(); } } private startPollingFallback(): void { if (this.intentionallyDisconnected) return; if (this.isRateLimited()) { return; } // Idempotent: a repeat setup (or an SSE->poll fallback that races another) // must not leak a second 3s interval alongside the first (#781). if (this.pollingInterval) 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'], ); } 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.firstTouchGate.run(methodCalls, () => 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; } // Coalesce overlapping polls: if one is already awaiting a response, skip // this call rather than issuing a duplicate POST (#781). The next tick // picks up any change once the in-flight one settles. if (this.stateCheckInFlight) { return; } this.stateCheckInFlight = true; try { const { using, methodCalls } = this.buildStatePollingRequest(); const response = await this.firstTouchGate.run(methodCalls, () => 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 } finally { this.stateCheckInFlight = false; } } closePushNotifications(): void { this.sseWanted = false; JMAPClient.releaseSSESlot(this); 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(); 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; } } /** * Drop and rebuild an SSE stream that has gone quiet past the ping deadline. * * The ping monitor already does this, but it is a `setInterval`, and a * suspended tab - an iOS home-screen web app in particular - freezes its * timers along with everything else. Coming back to a page whose push * connection died while it slept, the monitor needs up to a full tick just to * notice, and the connection it is holding is one the OS already tore down. * Checking on the way back in makes the recovery immediate rather than * leaving the app on a socket that will never deliver another byte. */ private recycleStaleSSE(): void { if (this.intentionallyDisconnected) return; if (!this.sseAbortController) return; if (Date.now() - this.lastSSEActivity <= JMAPClient.SSE_PING_TIMEOUT) return; this.stopSSEPingMonitor(); this.sseAbortController.abort(); this.sseAbortController = null; this.scheduleSSEReconnect(); } private setupBrowserEventListeners(): void { if (typeof document !== 'undefined') { this.visibilityHandler = () => { if (!document.hidden) { // Tab became visible - immediately check for state changes this.checkForStateChanges(); this.recycleStaleSSE(); } }; document.addEventListener('visibilitychange', this.visibilityHandler); } if (typeof window !== 'undefined') { this.onlineHandler = () => { // Network reconnected - reconnect SSE or force a poll 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.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, rangeHeader?: number): Promise { const url = this.getBlobDownloadUrl(blobId, name, type, accountId); const response = await this.authenticatedFetch(url, { }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS }); if (!response.ok) { throw new Error(`Failed to fetch blob: ${response.status}`); } const maxBytes = rangeHeader; // If no Range header requested or stream is missing, fallback to standard arrayBuffer() if (!maxBytes || !response.body) { return response.arrayBuffer(); } // used to download only the first N bytes of a blob, e.g. for S/MIME /PGP encryption verification // Currently, Stalwart server does not support Range requests for the download endpoint, // so we have to read the stream and cancel it after reading the first N bytes. // Stream reading with early termination via reader.cancel() const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let bytesRead = 0; try { while (bytesRead < maxBytes) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); bytesRead += value.length; } } finally { // Cancel the response body stream to close the network connection early await reader.cancel().catch(() => {}); } // Slice exact requested length and copy chunks into a contiguous ArrayBuffer const totalLength = Math.min(bytesRead, maxBytes); const result = new Uint8Array(totalLength); let offset = 0; // Iterate over each chunk of binary data (Uint8Array) collected during the stream for (const chunk of chunks) { // Determine how many bytes to copy from the current chunk without exceeding totalLength const bytesToCopy = Math.min(chunk.length, totalLength - offset); // Copy the needed slice from the chunk into the target array starting at the current offset result.set(chunk.subarray(0, bytesToCopy), offset); // Move the offset forward by the number of bytes just written offset += bytesToCopy; // Stop processing further chunks if the target size limit has been reached if (offset >= totalLength) break; } return result.buffer; } /** * 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 const { failure } = sendMethodErrors(response.methodResponses); if (failure) { throw new Error(failure.description || `Failed: ${failure.type}`); } for (const [methodName, result] of response.methodResponses ?? []) { const r = result as { notCreated?: Record }; if (r.notCreated) { const firstErr = Object.values(r.notCreated)[0]; this.throwIfHoldTooLong(firstErr); 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; } } // These raw/S-MIME paths submit through the primary submission account, so // report that as the owning account rather than leaving it unset. const submissionAccountId = this.getSubmissionAccountId(); if (delayedUntil && emailSubmissionId && !serverSendAt) { serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId); } return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true } : { scheduled: false, emailId, emailSubmissionId, submissionAccountId, 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; const { failure } = sendMethodErrors(response.methodResponses); if (failure) { throw new Error(failure.description || `Failed: ${failure.type}`); } for (const [methodName, result] of response.methodResponses ?? []) { const r = result as { notCreated?: Record }; if (r.notCreated) { const firstErr = Object.values(r.notCreated)[0]; this.throwIfHoldTooLong(firstErr); 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; } } const submissionAccountId = this.getSubmissionAccountId(); if (delayedUntil && emailSubmissionId && !serverSendAt) { serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId); } return delayedUntil ? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true } : { scheduled: false, emailSubmissionId, submissionAccountId, isSmime: true }; } async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; totalByAccount?: Record; nextPosition: number }> { // Scheduled mail composed from a shared identity lives in the shared // account, so gate on *any* reachable submission account supporting // delayed send rather than only the primary one. const submissionAccountIds = this.getSubmissionAccountIds() .filter(accountId => this.hasDelayedSend(accountId)); if (submissionAccountIds.length === 0) { return { emails: [], hasMore: false, total: 0, nextPosition: position }; } const now = Date.now(); const pageSize = Math.max(limit, 50); const submissions: EmailSubmission[] = []; // Which account each submission came from — needed to fetch its Email from // the right account below, and to route later cancel/reschedule calls. const accountBySubmissionId = new Map(); const primarySubmissionAccountId = this.getSubmissionAccountId(); for (const submissionAccountId of submissionAccountIds) { let rawPosition = 0; let rawTotal = 0; try { do { const queryResponse = await this.request([ ['EmailSubmission/query', { accountId: submissionAccountId, 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: submissionAccountId, ids, properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'], }, '0'], ]); const pending = ((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; }); for (const submission of pending) accountBySubmissionId.set(submission.id, submissionAccountId); submissions.push(...pending); rawPosition += ids.length; } while (rawPosition < rawTotal); } catch (error) { // A shared account that cannot answer (revoked access, server-side // error) must not take the user's own scheduled mail down with it; // the primary account's failure still surfaces as before. if (submissionAccountId === primarySubmissionAccountId) throw error; console.error(`[getScheduledEmails] shared account ${submissionAccountId} failed:`, error); } } submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime()); const total = submissions.length; // Per-account totals over ALL pending submissions, not just the page below, // so a shared account's badge stays correct past the first page. const totalByAccount: Record = {}; for (const submission of submissions) { const accountId = accountBySubmissionId.get(submission.id); if (accountId) totalByAccount[accountId] = (totalByAccount[accountId] ?? 0) + 1; } const pageSubmissions = submissions.slice(position, position + limit); const nextPosition = position + pageSubmissions.length; if (pageSubmissions.length === 0) { return { emails: [], hasMore: false, total, totalByAccount, nextPosition }; } // The submission's Email lives in the same account as the submission, so a // shared-account scheduled message must be fetched from there — asking the // primary account returns nothing and the row would be dropped below. const emailIdsByAccount = new Map(); for (const submission of pageSubmissions) { const accountId = accountBySubmissionId.get(submission.id) ?? this.accountId; const bucket = emailIdsByAccount.get(accountId); if (bucket) bucket.push(submission.emailId); else emailIdsByAccount.set(accountId, [submission.emailId]); } const emailById = new Map(); for (const [accountId, ids] of emailIdsByAccount) { const emailResponse = await this.request([ ['Email/get', { accountId, ids, 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 fetchedEmails = (emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[]; await this.refetchTruncatedBodyValues(fetchedEmails, accountId); for (const email of fetchedEmails) { emailById.set(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, scheduledAccountId: accountBySubmissionId.get(submission.id), 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, totalByAccount, nextPosition }; } async cancelEmailSubmission(submissionId: string, accountId?: string): Promise { // A submission created from a shared identity belongs to the shared // account; cancelling it through the primary account answers `notFound` // and the message would still go out. See #874. const submissionAccountId = accountId ?? await this.resolveSubmissionAccountId(submissionId); const response = await this.request([ ['EmailSubmission/set', { accountId: submissionAccountId, 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, accountId?: string): Promise { // Keep the replacement in the same account as the original: a shared // submission rescheduled through the primary account would be recreated // in the wrong account (and the original left running). See #874. const submissionAccountId = accountId ?? await this.resolveSubmissionAccountId(submissionId); const holdForSeconds = this.validateDelayedUntil(delayedUntil, submissionAccountId); const mailboxes = await this.getMailboxes(submissionAccountId); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); // The identity and the Email live beside the submission, so look both up in // the owning account. getIdentities() only knows the primary account's // identities; a shared identity missing there would silently put the login // user's own address into MAIL FROM of the replacement. let identity: Identity | undefined; try { const identityResponse = await this.request([ ['Identity/get', { accountId: submissionAccountId, ids: [identityId], properties: ['id', 'email'] }, '0'], ]); identity = ((identityResponse.methodResponses?.[0]?.[1]?.list ?? []) as Identity[]) .find(item => item.id === identityId); } catch (error) { console.error('Failed to get identity for reschedule:', error); } const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId, submissionAccountId); const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId, submissionAccountId); 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: submissionAccountId, 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, submissionAccountId); try { await this.cancelEmailSubmission(submissionId, submissionAccountId); } catch (error) { try { await this.cancelEmailSubmission(replacementId, submissionAccountId); } 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. // draft-ietf-jmap-emailpush: the session advertises this when // PushSubscription accepts a per-account `emailPush` delivery filter. // Stalwart >= 0.16.16. Older servers reject the capability in `using` and // the `emailPush` property outright, so every push call gates on it. static readonly EMAIL_PUSH_CAPABILITY = 'urn:ietf:params:jmap:emailpush'; hasEmailPushCapability(): boolean { return this.hasCapability(JMAPClient.EMAIL_PUSH_CAPABILITY); } private pushUsing(withEmailPush: boolean): string[] { return withEmailPush ? ['urn:ietf:params:jmap:core', JMAPClient.EMAIL_PUSH_CAPABILITY] : ['urn:ietf:params:jmap:core']; } async listPushSubscriptions(): Promise { const emailPush = this.hasEmailPushCapability(); // `emailPush` is not in the server's default property set, so ask for it // explicitly - but only where the server knows the property at all. const args: Record = { ids: null }; if (emailPush) { args.properties = ['id', 'deviceClientId', 'verificationCode', 'expires', 'types', 'emailPush']; } const response = await this.request( [['PushSubscription/get', args, '0']], this.pushUsing(emailPush), ); const [, body] = response.methodResponses[0] ?? []; return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? []; } async createPushSubscription(params: { deviceClientId: string; url: string; types: string[]; expires?: string; emailPush?: Record; }): Promise { const created: Record = { deviceClientId: params.deviceClientId, url: params.url, types: params.types, }; if (params.expires) created.expires = params.expires; const withEmailPush = !!params.emailPush && this.hasEmailPushCapability(); if (withEmailPush) created.emailPush = params.emailPush; const response = await this.request( [['PushSubscription/set', { create: { new: created } }, '0']], this.pushUsing(withEmailPush), ); 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[]; emailPush?: Record | null }, ): Promise { const withEmailPush = patch.emailPush !== undefined && this.hasEmailPushCapability(); const update: Record = { ...patch }; if (!withEmailPush) delete update.emailPush; const response = await this.request( [['PushSubscription/set', { update: { [id]: update } }, '0']], this.pushUsing(withEmailPush), ); 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'], ); } }