mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Stalwart advertises `maxDelayedSend` as a fixed 30 days, but its MTA rejects any hold beyond `futureRelease`, 7 days by default. Scheduling a message 8 to 30 days ahead passed the picker and then failed with "501 5.5.4 Requested hold time exceeds maximum of 604800 seconds". On Stalwart the advertised 30 days now counts as 7. When a submission is still rejected for its hold time, the limit from the error message replaces it for the session, and the composer shows the translated "later than the server allows" message instead of the raw SMTP reply.
8970 lines
347 KiB
TypeScript
8970 lines
347 KiB
TypeScript
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 { getEffectiveTimeZone } 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<T extends Pick<FileNode, 'name'>>(node: T): T {
|
||
const name = decodeFileNodeName(node.name);
|
||
return name === node.name ? node : { ...node, name };
|
||
}
|
||
|
||
/**
|
||
* Parse a recipient string that may be "Name <email>" 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: { <id>: true, ... } }` — instead of per-id
|
||
* `mailboxIds/<id>` 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/<id>` 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<string, true> } {
|
||
const mailboxIds: Record<string, true> = { [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<string, string>;
|
||
accounts?: Record<string, JMAPAccount>;
|
||
capabilities?: Record<string, unknown>;
|
||
}
|
||
|
||
interface JMAPAccount {
|
||
name?: string;
|
||
isPersonal?: boolean;
|
||
isReadOnly?: boolean;
|
||
accountCapabilities?: Record<string, unknown>;
|
||
}
|
||
|
||
interface JMAPQuota {
|
||
resourceType?: string;
|
||
scope?: string;
|
||
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<string, boolean>;
|
||
}
|
||
|
||
interface JMAPEmailHeader {
|
||
name: string;
|
||
value: string;
|
||
}
|
||
|
||
type JMAPMethodCall = [string, Record<string, unknown>, 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<string, any>;
|
||
|
||
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<string, CalendarTask['progress']>([
|
||
['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<string, unknown>): void {
|
||
const keyMap: Record<string, string> = {
|
||
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<string, unknown>;
|
||
const cleaned: Record<string, unknown> = {};
|
||
for (const [k, v] of Object.entries(rule)) {
|
||
if (v !== null) cleaned[k] = v;
|
||
}
|
||
event[singularKey] = cleaned;
|
||
}
|
||
}
|
||
|
||
function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | 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<string, boolean> = {};
|
||
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 `"<Method>/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 };
|
||
}
|
||
|
||
/** 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<string, unknown> {
|
||
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 <addr@example.com>`. Re-emitting that as the JMAP
|
||
// from.name field produces a doubled From header (`"Name <addr>" <addr>`)
|
||
// 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<string | EmailAddress>): 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 <addr>"; 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<string, string | null>;
|
||
rcptTo: Record<string, string | null>;
|
||
} {
|
||
const mailFrom: Record<string, string | null> = {};
|
||
const rcptTo: Record<string, string | null> = {};
|
||
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<string | EmailAddress>, options?: SubmissionSendOptions): Record<string, unknown> | 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<string | null>;
|
||
private onTotpRequired?: () => Promise<string | null>;
|
||
private apiUrl: string = "";
|
||
private accountId: string = "";
|
||
private downloadUrl: string = "";
|
||
private capabilities: Record<string, unknown> = {};
|
||
private session: JMAPSession | null = null;
|
||
private lastPingTime: number = 0;
|
||
private pingInterval: NodeJS.Timeout | null = null;
|
||
// 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<string, JMAPAccount> = {};
|
||
private eventSource: EventSource | null = null;
|
||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||
private lastStates: AccountStates = {};
|
||
private reconnecting = false;
|
||
private connectionChangeCallback: ((connected: boolean) => void) | null = null;
|
||
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<string, { value: KeywordSortPolarity | null; at: number }>();
|
||
private keywordSortProbes = new Map<string, Promise<KeywordSortPolarity>>();
|
||
// Accounts whose server refused a hasKeyword comparator with unsupportedSort.
|
||
private keywordSortUnsupported = new Set<string>();
|
||
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<string | null>,
|
||
): JMAPClient {
|
||
const client = new JMAPClient(serverUrl, username, '');
|
||
client.authMode = 'bearer';
|
||
client.authHeader = `Bearer ${accessToken}`;
|
||
client.onTokenRefresh = onTokenRefresh;
|
||
return client;
|
||
}
|
||
|
||
updateAccessToken(token: string): void {
|
||
this.authHeader = `Bearer ${token}`;
|
||
}
|
||
|
||
async getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]> {
|
||
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<string | null>): 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<string | null>): 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<typeof fetch>[1],
|
||
headers: Record<string, string>,
|
||
timeoutMs: number,
|
||
): Promise<Response> {
|
||
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<typeof fetch>[1],
|
||
opts?: { timeoutMs?: number },
|
||
): Promise<Response> {
|
||
// 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<string, string>, '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<string, string>, '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<string, string>, '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<string, string>, '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<Response> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
if (!this.apiUrl) {
|
||
throw new Error('Not connected');
|
||
}
|
||
|
||
const now = Date.now();
|
||
const response = await this.request([
|
||
["Core/echo", { ping: "pong" }, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] !== "Core/echo") {
|
||
throw new Error('Ping failed');
|
||
}
|
||
this.lastPingTime = now;
|
||
}
|
||
|
||
async reconnect(): Promise<void> {
|
||
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<JMAPResponse> {
|
||
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<Mailbox[]> {
|
||
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<Mailbox[]> {
|
||
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<string, string> }> {
|
||
try {
|
||
const allMailboxes: Mailbox[] = [];
|
||
const states: Record<string, string> = {};
|
||
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<Mailbox[]> {
|
||
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<CollectionChanges | null> {
|
||
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<CollectionChanges | null> {
|
||
return this.getCollectionChanges('Email', sinceState, accountId, maxChanges);
|
||
}
|
||
|
||
/** Mailbox/changes since `sinceState` for the account (see getCollectionChanges). */
|
||
getMailboxChanges(sinceState: string, accountId?: string, maxChanges?: number): Promise<CollectionChanges | null> {
|
||
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<KeywordSortPolarity> {
|
||
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<KeywordSortPolarity | null> {
|
||
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<typeof buildEmailSort>; 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<string, unknown>, 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<string, unknown> = 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<typeof buildEmailSort>) => 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<Email[]> {
|
||
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<Record<string, { total: number; unread: number }>> {
|
||
if (tagIds.length === 0) return {};
|
||
const result: Record<string, { total: number; unread: number }> = {};
|
||
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<string, number>; 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<string, number> = {};
|
||
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<string, boolean> }>;
|
||
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<KeywordDiscoveryResult> {
|
||
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<string, unknown> | null }>,
|
||
accountId?: string,
|
||
): Promise<Record<string, number>> {
|
||
if (tabs.length === 0) return {};
|
||
const targetAccountId = accountId || this.accountId;
|
||
const result: Record<string, number> = {};
|
||
|
||
for (const batch of batched(tabs, this.getMaxCallsInRequest())) {
|
||
try {
|
||
const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => {
|
||
const conditions: Record<string, unknown>[] = [
|
||
{ 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<Email | null> {
|
||
try {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
const response = await this.request([
|
||
["Email/get", {
|
||
accountId: targetAccountId,
|
||
ids: [emailId],
|
||
properties: [
|
||
"id", "threadId", "mailboxIds", "keywords", "size",
|
||
"receivedAt", "sentAt", "from", "to", "cc", "bcc", "replyTo",
|
||
"subject", "preview", "textBody", "htmlBody", "bodyValues",
|
||
"hasAttachment", "attachments", "messageId", "inReplyTo",
|
||
"references", "headers", "bodyStructure", "blobId",
|
||
],
|
||
fetchTextBodyValues: true,
|
||
fetchHTMLBodyValues: true,
|
||
fetchAllBodyValues: true,
|
||
maxBodyValueBytes: 256000,
|
||
}, "0"],
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] !== "Email/get") {
|
||
return null;
|
||
}
|
||
|
||
const email = (response.methodResponses[0][1].list || [])[0];
|
||
if (!email) return null;
|
||
|
||
if (accountId && accountId !== this.accountId) {
|
||
namespaceMailboxIds([email], accountId);
|
||
}
|
||
|
||
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<void> {
|
||
const truncated = emails.filter((email) => this.hasTruncatedDisplayedBody(email));
|
||
if (truncated.length === 0) return;
|
||
|
||
try {
|
||
const refetchedById = new Map<string, Email['bodyValues']>();
|
||
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<void> {
|
||
const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers');
|
||
|
||
let headersRecord: Record<string, string | string[]>;
|
||
if (Array.isArray(email.headers)) {
|
||
headersRecord = {};
|
||
for (const header of email.headers as unknown as JMAPEmailHeader[]) {
|
||
if (!header?.name || !header?.value) continue;
|
||
const existing = headersRecord[header.name];
|
||
if (existing) {
|
||
headersRecord[header.name] = Array.isArray(existing)
|
||
? [...existing, header.value]
|
||
: [existing, header.value];
|
||
} else {
|
||
headersRecord[header.name] = header.value;
|
||
}
|
||
}
|
||
email.headers = headersRecord;
|
||
} else {
|
||
headersRecord = email.headers as Record<string, string | string[]>;
|
||
}
|
||
|
||
const authResultsHeader = headersRecord['Authentication-Results'];
|
||
if (authResultsHeader) {
|
||
// 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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
"keywords/$flagged": starred ? true : null,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>, accountId?: string): Promise<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
keywords,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async setKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
[keywordPointer(keyword)]: true,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
|
||
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/<name>` pointers with true (add)
|
||
* or null (remove) values - the category-tab move primitive.
|
||
*/
|
||
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
|
||
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<KeywordMigration> {
|
||
// Query all email IDs that have the old keyword
|
||
const seen = new Set<string>();
|
||
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<string, Record<string, boolean | null>> = {};
|
||
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<string, { type?: string; description?: string }>;
|
||
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<string, { type?: string; description?: string }> = {
|
||
...(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<void> {
|
||
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<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const patch: Record<string, unknown> = { 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<void> {
|
||
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<void> {
|
||
if (emailIds.length === 0) return;
|
||
|
||
const buildPatch = () => {
|
||
const patch: Record<string, unknown> = { 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<void> {
|
||
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<string, Dest>();
|
||
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 ("#<cid>").
|
||
const yearIdFor = new Map<string, string>();
|
||
const monthIdFor = new Map<string, string>();
|
||
const createEntries: Record<string, Record<string, unknown>> = {};
|
||
|
||
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<string, { mailboxIds: Record<string, true> }> = {};
|
||
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 "#<cid>" 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<string, string> = {};
|
||
|
||
for (let i = 0; i < updateBatches.length; i++) {
|
||
const batch: Array<[string, { mailboxIds: Record<string, true> }]> = 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<string, true> } : 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<string, { type?: string; properties?: string[]; description?: string }> | 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<string, { id?: string }>;
|
||
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<string, { type?: string; description?: string }> | 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<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: {
|
||
[emailId]: {
|
||
mailboxIds: { [toMailboxId]: true },
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[emailId]) {
|
||
throw new Error(`Failed to move email: ${result.notUpdated[emailId].type || 'unknown error'}`);
|
||
}
|
||
}
|
||
|
||
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
|
||
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<number> {
|
||
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<number> {
|
||
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<string, boolean> }> = 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<void> {
|
||
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<string, unknown> = {
|
||
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<void> {
|
||
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<Mailbox> {
|
||
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<string, unknown> = { 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<void> {
|
||
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<void> {
|
||
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<string, unknown> = { text: wildcardQuery };
|
||
|
||
let filter: Record<string, unknown>;
|
||
if (mailboxId) {
|
||
filter = {
|
||
operator: "AND",
|
||
conditions: [
|
||
{ inMailbox: mailboxId },
|
||
textFilter,
|
||
],
|
||
};
|
||
} else {
|
||
filter = textFilter;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter,
|
||
sort: [{ property: "receivedAt", isAscending: false }],
|
||
limit,
|
||
position,
|
||
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<string, unknown> | 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<string, unknown>,
|
||
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<Array<{ name: string; email: string }>> {
|
||
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<string, { name: string; email: string }>();
|
||
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<Thread | null> {
|
||
try {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
const response = await this.request([
|
||
["Thread/get", {
|
||
accountId: targetAccountId,
|
||
ids: [threadId],
|
||
}, "0"],
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
|
||
const threads = response.methodResponses[0][1].list || [];
|
||
return threads[0] || null;
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
console.error('Failed to get thread:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async getThreads(threadIds: string[], accountId?: string): Promise<Thread[]> {
|
||
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<Email[]> {
|
||
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<Identity[]> {
|
||
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<Identity> {
|
||
const response = await this.request([
|
||
["Identity/set", {
|
||
accountId: this.accountId,
|
||
create: {
|
||
"new-identity": {
|
||
name,
|
||
email,
|
||
replyTo,
|
||
bcc,
|
||
textSignature,
|
||
htmlSignature,
|
||
}
|
||
}
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notCreated?.["new-identity"]) {
|
||
const error = result.notCreated["new-identity"];
|
||
if (error.type === "forbidden") {
|
||
throw new Error("You are not authorized to send from this email address");
|
||
}
|
||
throw new Error(error.description || "Failed to create identity");
|
||
}
|
||
|
||
const createdId = result.created?.["new-identity"]?.id;
|
||
if (createdId) {
|
||
const identities = await this.getIdentities();
|
||
const identity = identities.find(i => i.id === createdId);
|
||
if (identity) return identity;
|
||
}
|
||
}
|
||
|
||
throw new Error("Failed to create identity: Server response was unexpected. Check server logs.");
|
||
}
|
||
|
||
async updateIdentity(
|
||
identityId: string,
|
||
updates: {
|
||
name?: string | null;
|
||
replyTo?: EmailAddress[] | null;
|
||
bcc?: EmailAddress[] | null;
|
||
textSignature?: string | null;
|
||
htmlSignature?: string | null;
|
||
}
|
||
): Promise<void> {
|
||
const response = await this.request([
|
||
["Identity/set", {
|
||
accountId: this.accountId,
|
||
update: {
|
||
[identityId]: updates
|
||
}
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notUpdated?.[identityId]) {
|
||
const error = result.notUpdated[identityId];
|
||
if (error.type === "notFound") {
|
||
throw new Error("Identity not found (may have been deleted)");
|
||
}
|
||
if (error.type === "forbidden") {
|
||
throw new Error("You are not authorized to modify this identity");
|
||
}
|
||
throw new Error(error.description || "Failed to update identity");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to update identity: Server response was unexpected. Check server logs.");
|
||
}
|
||
|
||
async deleteIdentity(identityId: string): Promise<void> {
|
||
const response = await this.request([
|
||
["Identity/set", {
|
||
accountId: this.accountId,
|
||
destroy: [identityId]
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notDestroyed?.[identityId]) {
|
||
const error = result.notDestroyed[identityId];
|
||
if (error.type === "forbidden") {
|
||
throw new Error("This identity cannot be deleted");
|
||
}
|
||
if (error.type === "notFound") {
|
||
throw new Error("Identity not found (may already be deleted)");
|
||
}
|
||
throw new Error(error.description || "Failed to delete identity");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
|
||
}
|
||
|
||
private vacationUsing(): string[] {
|
||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"];
|
||
}
|
||
|
||
async getVacationResponse(accountId?: string): Promise<VacationResponse> {
|
||
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<VacationResponse>, accountId?: string): Promise<void> {
|
||
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<string> {
|
||
const mailboxes = await this.getMailboxes();
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
if (!draftsMailbox) {
|
||
throw new Error('No drafts mailbox found');
|
||
}
|
||
|
||
const emailId = `draft-${Date.now()}`;
|
||
|
||
interface EmailDraft {
|
||
from: { name?: string; email: string }[];
|
||
to: { name?: string; email: string }[];
|
||
cc?: { name?: string; email: string }[];
|
||
bcc?: { name?: string; email: string }[];
|
||
subject: string;
|
||
keywords: Record<string, boolean>;
|
||
mailboxIds: Record<string, boolean>;
|
||
bodyValues: Record<string, { value: string }>;
|
||
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 <addr>" 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<addr` (#672).
|
||
to: to.map(parseRecipientString),
|
||
cc: cc?.length ? cc.map(parseRecipientString) : undefined,
|
||
bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined,
|
||
subject,
|
||
keywords: { "$seen": true, "$draft": true },
|
||
mailboxIds: { [draftsMailbox.id]: true },
|
||
bodyValues: htmlBody
|
||
? { "text": { value: body }, "html": { value: htmlBody } }
|
||
: { "1": { value: body } },
|
||
textBody: htmlBody
|
||
? [{ partId: "text", type: "text/plain" }]
|
||
: [{ partId: "1" }],
|
||
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
|
||
};
|
||
|
||
if (attachments?.length) {
|
||
emailData.attachments = attachments.map(att => ({
|
||
blobId: att.blobId,
|
||
type: att.type,
|
||
name: att.name,
|
||
disposition: att.disposition ?? "attachment",
|
||
...(att.cid ? { cid: att.cid } : {}),
|
||
}));
|
||
}
|
||
|
||
// 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<SendEmailResult> {
|
||
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<string, unknown> = {
|
||
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:<Name>: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<string, unknown> => {
|
||
const create: Record<string, unknown> = { 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<string, {
|
||
type?: string;
|
||
description?: string;
|
||
properties?: string[];
|
||
}>;
|
||
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<string, { type?: string; description?: string }>)[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<string, { type?: string; description?: string }>)[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<string, { type?: string; description?: string }>)[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<void> {
|
||
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<string, string> = {
|
||
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<string, unknown> = {
|
||
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<void> {
|
||
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<string, unknown> = {
|
||
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<void> {
|
||
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<string, unknown> = {
|
||
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<string> {
|
||
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<string, boolean>,
|
||
keywords?: Record<string, boolean>,
|
||
accountId?: string
|
||
): Promise<string | null> {
|
||
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<string, { id: string }>;
|
||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||
};
|
||
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<void> {
|
||
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<string, { type?: string; description?: string }> })?.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<Blob> {
|
||
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<string> {
|
||
const blob = await this.fetchBlob(blobId, name, type, accountId);
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
getCapabilities(): Record<string, unknown> {
|
||
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<string | undefined> {
|
||
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<string> {
|
||
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<string, unknown>)
|
||
.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<SieveScript[]> {
|
||
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<string> {
|
||
// 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<string> {
|
||
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<SieveScript> {
|
||
const targetAccountId = accountId || this.getSieveAccountId();
|
||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
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<void> {
|
||
const targetAccountId = accountId || this.getSieveAccountId();
|
||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<AddressBook[]> {
|
||
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<AddressBook[]> {
|
||
try {
|
||
const allBooks: AddressBook[] = [];
|
||
const primaryId = this.getContactsAccountId();
|
||
const accountIds = this.getContactCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const response = await this.request([
|
||
["AddressBook/get", { accountId, 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<AddressBook> {
|
||
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<AddressBook>, targetAccountId?: string): Promise<void> {
|
||
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<string, unknown>;
|
||
const patch: Record<string, unknown> = {};
|
||
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<void> {
|
||
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): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
const response = await this.request([
|
||
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
|
||
], this.contactUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notDestroyed?.[addressBookId]) {
|
||
const err = result.notDestroyed[addressBookId];
|
||
throw new Error(err.description || "Failed to delete address book");
|
||
}
|
||
}
|
||
|
||
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
|
||
|
||
private principalsUsing(): string[] {
|
||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
|
||
}
|
||
|
||
/**
|
||
* List all principals visible to the user (RFC 9670). Stalwart returns the
|
||
* full directory regardless of `filter`, so we fetch the whole list and let
|
||
* callers filter client-side.
|
||
*/
|
||
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
|
||
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<CalendarEventNotification[]> {
|
||
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<void> {
|
||
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<CalendarParticipantIdentity[]> {
|
||
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<Partial<CalendarParticipantIdentity> & { 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<void> {
|
||
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<BusyPeriod[]> {
|
||
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<ShareNotification[]> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<Record<string, MailboxRights> | 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<string, MailboxRights> | 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<void> {
|
||
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<string, unknown>; notUpdated?: Record<string, { description?: string }> };
|
||
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<string, unknown>,
|
||
): Promise<ContactCard[]> {
|
||
const batchSize = this.getMaxObjectsInGet();
|
||
const allIds: string[] = [];
|
||
let position = 0;
|
||
|
||
// Paginate ContactCard/query to collect all IDs
|
||
for (;;) {
|
||
const queryArgs: Record<string, unknown> = { accountId, position, limit: batchSize };
|
||
if (filter) {
|
||
queryArgs.filter = filter;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["ContactCard/query", queryArgs, "q"],
|
||
], this.contactUsing());
|
||
|
||
const queryResult = response.methodResponses?.[0];
|
||
if (queryResult?.[0] !== "ContactCard/query") break;
|
||
|
||
const ids: string[] = queryResult[1].ids || [];
|
||
allIds.push(...ids);
|
||
|
||
const total: number = queryResult[1].total ?? -1;
|
||
if (ids.length < batchSize || (total > 0 && allIds.length >= total)) {
|
||
break;
|
||
}
|
||
position += ids.length;
|
||
}
|
||
|
||
if (allIds.length === 0) return [];
|
||
|
||
// Batch ContactCard/get to respect maxObjectsInGet
|
||
const allContacts: ContactCard[] = [];
|
||
for (let i = 0; i < allIds.length; i += batchSize) {
|
||
const chunk = allIds.slice(i, i + batchSize);
|
||
const response = await this.request([
|
||
["ContactCard/get", { accountId, ids: chunk }, "g"],
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
|
||
allContacts.push(...list);
|
||
}
|
||
}
|
||
|
||
return allContacts;
|
||
}
|
||
|
||
async getContacts(addressBookId?: string, options?: { throwOnError?: boolean }): Promise<ContactCard[]> {
|
||
try {
|
||
const accountId = this.getContactsAccountId();
|
||
const filter = addressBookId ? { inAddressBook: addressBookId } : undefined;
|
||
return await this.fetchPaginatedContacts(accountId, filter);
|
||
} catch (error) {
|
||
console.error('Failed to get contacts:', error);
|
||
if (options?.throwOnError) throw error;
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async getAllContacts(): Promise<ContactCard[]> {
|
||
try {
|
||
const allContacts: ContactCard[] = [];
|
||
const primaryId = this.getContactsAccountId();
|
||
const accountIds = this.getContactCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const rawContacts = await this.fetchPaginatedContacts(accountId);
|
||
const contacts = rawContacts.map((contact) => ({
|
||
...contact,
|
||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||
originalId: contact.id,
|
||
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
||
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
||
) : contact.addressBookIds),
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}));
|
||
allContacts.push(...contacts);
|
||
} catch (error) {
|
||
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allContacts;
|
||
} catch (error) {
|
||
console.error('Failed to fetch all contacts:', error);
|
||
return this.getContacts();
|
||
}
|
||
}
|
||
|
||
async getContact(contactId: string, accountId?: string): Promise<ContactCard | null> {
|
||
try {
|
||
const targetAccountId = accountId || this.getContactsAccountId();
|
||
const response = await this.request([
|
||
["ContactCard/get", {
|
||
accountId: targetAccountId,
|
||
ids: [contactId],
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||
const list = response.methodResponses[0][1].list || [];
|
||
return list[0] || null;
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
console.error('Failed to get contact:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
let addressBookIds = contact.addressBookIds;
|
||
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
||
const books = await this.getAddressBooks();
|
||
const defaultBook = books.find(b => b.isDefault) || books[0];
|
||
if (defaultBook) {
|
||
addressBookIds = { [defaultBook.id]: true };
|
||
}
|
||
}
|
||
|
||
// Strip shared-only fields before sending to JMAP
|
||
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...contactData } = contact as ContactCard;
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
create: {
|
||
"new-contact": {
|
||
...contactData,
|
||
// Stalwart stores the card without one if omitted (#644)
|
||
uid: contactData.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<ContactCard>, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
|
||
// Strip shared-only fields before sending to JMAP
|
||
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...cleanUpdates } = updates as ContactCard;
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
update: {
|
||
[contactId]: cleanUpdates
|
||
}
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notUpdated?.[contactId]) {
|
||
const error = result.notUpdated[contactId];
|
||
throw new Error(error.description || "Failed to update contact");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to update contact");
|
||
}
|
||
|
||
async deleteContact(contactId: string, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
destroy: [contactId]
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notDestroyed?.[contactId]) {
|
||
const error = result.notDestroyed[contactId];
|
||
throw new Error(error.description || "Failed to delete contact");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to delete contact");
|
||
}
|
||
|
||
async searchContacts(query: string): Promise<ContactCard[]> {
|
||
try {
|
||
const allResults: ContactCard[] = [];
|
||
const primaryId = this.getContactsAccountId();
|
||
const accountIds = this.getContactCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const response = await this.request([
|
||
["ContactCard/query", {
|
||
accountId,
|
||
filter: { text: query },
|
||
limit: 50,
|
||
}, "0"],
|
||
["ContactCard/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||
}, "1"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
||
const contacts = rawContacts.map((contact) => ({
|
||
...contact,
|
||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||
originalId: contact.id,
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}));
|
||
allResults.push(...contacts);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to search contacts for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allResults;
|
||
} catch (error) {
|
||
console.error('Failed to search contacts:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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<JMAPResponse> {
|
||
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<Calendar[]> {
|
||
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<Set<string>> {
|
||
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<string, unknown> = { 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<Calendar[]> {
|
||
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<Calendar>, targetAccountId?: string, options?: CreateCalendarOptions): Promise<Calendar> {
|
||
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<Calendar | null> {
|
||
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<Calendar[]> {
|
||
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<Calendar>,
|
||
accountId: string,
|
||
components?: CalendarComponentType[],
|
||
): Promise<Calendar | null> {
|
||
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<string>;
|
||
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<string, unknown> = {};
|
||
for (const [key, value] of Object.entries(calendar as Record<string, unknown>)) {
|
||
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<Calendar>, accountId);
|
||
return (await this.getCalendarById(accountId, found.id)) ?? { ...found, ...updates } as Calendar;
|
||
}
|
||
|
||
async updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void> {
|
||
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<string, unknown> = {};
|
||
for (const [key, value] of Object.entries(updates as Record<string, unknown>)) {
|
||
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<void> {
|
||
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<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
const response = await this.request([
|
||
["Calendar/set", {
|
||
accountId,
|
||
destroy: [calendarId],
|
||
onDestroyRemoveEvents: true
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notDestroyed?.[calendarId]) {
|
||
const error = result.notDestroyed[calendarId];
|
||
throw new Error(error.description || "Failed to delete calendar");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to delete calendar");
|
||
}
|
||
|
||
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const 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<string, unknown> = { 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<string>();
|
||
|
||
// Result of the one-off synthetic-id capability probe, shared by every
|
||
// range query on this client (see supportsSyntheticCalendarIds).
|
||
private syntheticCalendarIdSupport: Promise<boolean> | 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<boolean> {
|
||
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<JMAPResponse>): 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<string, { type?: string }> } | 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<CalendarEvent[]> {
|
||
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<CalendarEvent[]> {
|
||
try {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const timeZone = getUserTimeZone();
|
||
|
||
const queryArgs: Record<string, unknown> = {
|
||
accountId,
|
||
filter,
|
||
limit: limit || 1000,
|
||
};
|
||
// Interpret the LocalDateTime after/before filter values in the user's
|
||
// time zone (Stalwart defaults to UTC, shifting range boundaries).
|
||
if (timeZone) {
|
||
queryArgs.timeZone = timeZone;
|
||
}
|
||
// 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.
|
||
const 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();
|
||
|
||
// First, query to get IDs
|
||
const queryResponse = await this.request([
|
||
["CalendarEvent/query", queryArgs, "0"],
|
||
], this.calendarUsing());
|
||
|
||
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||
const error = queryResponse.methodResponses[0][1];
|
||
// Keep the JMAP error type so the catch below can tell an expected
|
||
// access rejection apart from a genuine failure.
|
||
throw Object.assign(
|
||
new Error(error?.description || error?.type || "CalendarEvent/query failed"),
|
||
{ jmapErrorType: error?.type },
|
||
);
|
||
}
|
||
|
||
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||
if (ids.length === 0) return [];
|
||
|
||
// Batch the /get calls to stay within server max-objects limit
|
||
const allEvents: CalendarEvent[] = [];
|
||
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||
const getResponse = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||
ids: batchIds,
|
||
...(timeZone ? { timeZone } : {}),
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
|
||
allEvents.push(...events);
|
||
}
|
||
}
|
||
|
||
const 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<CalendarEvent[]> {
|
||
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<string, Partial<CalendarEvent>>();
|
||
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<CalendarEvent>[]) {
|
||
if (base.id) bases.set(base.id, normalizeCalendarEventLike(base));
|
||
}
|
||
}
|
||
return hydrateRecurrenceInstances(events, bases);
|
||
}
|
||
|
||
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
|
||
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<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent> {
|
||
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<string, unknown>);
|
||
|
||
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<string, unknown> = {
|
||
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<CalendarEvent>[],
|
||
targetAccountId?: string,
|
||
): Promise<{ created: CalendarEvent[]; failed: string[]; notCreated: Record<string, CalendarSetError> }> {
|
||
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<string, CalendarSetError> = {};
|
||
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<string, Partial<CalendarEvent>> = {};
|
||
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<string, unknown>);
|
||
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<CalendarEvent>,
|
||
sendSchedulingMessages?: boolean,
|
||
targetAccountId?: string
|
||
): Promise<void> {
|
||
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<string, unknown>);
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
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<string, unknown>).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<Partial<CalendarEvent>[]> {
|
||
const response = await this.request([
|
||
["CalendarEvent/parse", {
|
||
accountId,
|
||
blobIds: [blobId],
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/parse") {
|
||
const result = response.methodResponses[0][1];
|
||
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<CalendarEvent>));
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
throw new Error("Failed to parse calendar file");
|
||
}
|
||
|
||
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
accountId,
|
||
destroy: [eventId]
|
||
};
|
||
if (sendSchedulingMessages !== undefined) {
|
||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||
}
|
||
|
||
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<string, CalendarSetError> }> {
|
||
if (eventIds.length === 0) return { destroyed: [], notDestroyed: {} };
|
||
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const destroyed: string[] = [];
|
||
const notDestroyed: Record<string, CalendarSetError> = {};
|
||
|
||
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<CalendarTask[]> {
|
||
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<string, unknown> = { 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<string, unknown>[] = [];
|
||
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<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const { '@type': _type, progressUpdated: _pu, ...taskData } = task as Partial<CalendarTask> & { 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<CalendarTask>, targetAccountId?: string): Promise<void> {
|
||
await this.updateCalendarEvent(taskId, updates as unknown as Partial<CalendarEvent>, false, targetAccountId);
|
||
}
|
||
|
||
async deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void> {
|
||
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<boolean> {
|
||
// Some servers support FileNode without advertising a specific capability.
|
||
// Try a minimal FileNode/query to detect support at runtime.
|
||
if (this.supportsFiles()) return true;
|
||
// If 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<FileNode[]> {
|
||
const accountId = this.getFilesAccountId();
|
||
const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };
|
||
|
||
const response = await this.request(
|
||
[["FileNode/get", args, "fn0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/get failed");
|
||
}
|
||
return ((result[1].list || []) as FileNode[]).map(withDecodedName);
|
||
}
|
||
|
||
async queryFileNodes(filter: FileNodeFilter, sort?: { property: string; isAscending: boolean }[]): Promise<string[]> {
|
||
const accountId = this.getFilesAccountId();
|
||
const args: Record<string, unknown> = { accountId, filter };
|
||
if (sort) args.sort = sort;
|
||
|
||
const response = await this.request(
|
||
[["FileNode/query", args, "fnq0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/query failed");
|
||
}
|
||
return (result[1].ids || []) as string[];
|
||
}
|
||
|
||
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
||
// 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<FileNode[]> {
|
||
const nodes = await this.fetchAllFileNodes(this.getFilesAccountId());
|
||
return nodes.map(withDecodedName);
|
||
}
|
||
|
||
/**
|
||
* 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<FileNode[]> {
|
||
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<string>();
|
||
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<string>();
|
||
for (;;) {
|
||
const parents = new Set<string>();
|
||
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<FileNode[]> {
|
||
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({
|
||
...withDecodedName(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<void> {
|
||
const accountId = targetAccountId || this.getFilesAccountId();
|
||
const response = await this.request([
|
||
["FileNode/set", {
|
||
accountId,
|
||
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
|
||
}, "0"],
|
||
], this.fileUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[fileNodeId]) {
|
||
const err = result.notUpdated[fileNodeId];
|
||
throw new Error(err.description || "Failed to update file share");
|
||
}
|
||
if (!result?.updated || !(fileNodeId in result.updated)) {
|
||
throw new Error("Server did not confirm the share update");
|
||
}
|
||
}
|
||
|
||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
// A FileNode is a folder (container) only when it has no content - i.e. no
|
||
// blobId, type or size, so the server stores it with `file == null`. Sending
|
||
// any of those - as older builds did (type "d" + an empty blob) - makes it a
|
||
// 0-byte FILE that nothing can ever be parented under, which is what caused
|
||
// "Parent ID does not exist or is not a folder" during the #379 migration.
|
||
const dirProps: Record<string, unknown> = { name };
|
||
if (parentId !== null) {
|
||
dirProps.parentId = parentId;
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
create: {
|
||
dir0: dirProps,
|
||
},
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set create failed");
|
||
}
|
||
const created = result[1].created?.dir0;
|
||
if (!created) {
|
||
const err = result[1].notCreated?.dir0;
|
||
throw new Error(err?.description || "Failed to create directory");
|
||
}
|
||
return created as FileNode;
|
||
}
|
||
|
||
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
//fall back for long MIME types
|
||
const safeType = type.length > 30 ? 'application/octet-stream' : type;
|
||
const fileProps: Record<string, unknown> = { name, type: safeType, blobId, size };
|
||
if (parentId !== null) {
|
||
fileProps.parentId = parentId;
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
create: {
|
||
file0: fileProps,
|
||
},
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set create failed");
|
||
}
|
||
const created = result[1].created?.file0;
|
||
if (!created) {
|
||
const err = result[1].notCreated?.file0;
|
||
throw new Error(err?.description || "Failed to create file node");
|
||
}
|
||
return created as FileNode;
|
||
}
|
||
|
||
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
update: { [id]: updates },
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||
}
|
||
if (result[1].notUpdated?.[id]) {
|
||
throw new Error(result[1].notUpdated[id].description || "Failed to update file node");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||
const entries = Object.entries(updates);
|
||
if (entries.length === 0) return { updated: [], notUpdated: {} };
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
const updated: string[] = [];
|
||
const notUpdated: Record<string, string> = {};
|
||
|
||
for (const batch of batched(entries, this.getMaxObjectsInSet())) {
|
||
const response = await this.request(
|
||
[["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||
}
|
||
|
||
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||
for (const id of Object.keys(notUpdatedMap)) {
|
||
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
||
}
|
||
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||
updated.push(...(Object.keys(updatedMap).length > 0
|
||
? Object.keys(updatedMap)
|
||
: batch.map(([id]) => id).filter(id => !(id in notUpdated))));
|
||
}
|
||
|
||
return { updated, notUpdated };
|
||
}
|
||
|
||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||
const accountId = this.getFilesAccountId();
|
||
const destroyed: string[] = [];
|
||
|
||
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
destroy: batch,
|
||
onDestroyRemoveChildren: true,
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
||
}
|
||
|
||
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
|
||
const notDestroyedIds = Object.keys(notDestroyedMap);
|
||
|
||
if (notDestroyedIds.length > 0) {
|
||
const firstError = notDestroyedMap[notDestroyedIds[0]];
|
||
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
||
}
|
||
|
||
destroyed.push(...(result[1].destroyed || []));
|
||
}
|
||
|
||
return { destroyed, notDestroyed: [] };
|
||
}
|
||
|
||
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||
// Copy: get original, upload blob reference, create new node
|
||
const nodes = await this.getFileNodes([id]);
|
||
if (nodes.length === 0) throw new Error('File node not found');
|
||
const original = nodes[0];
|
||
|
||
const accountId = this.getFilesAccountId();
|
||
const createProps: Record<string, unknown> = {
|
||
name: newName,
|
||
type: original.type,
|
||
blobId: original.blobId,
|
||
size: original.size,
|
||
};
|
||
if (parentId !== null) {
|
||
createProps.parentId = parentId;
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
create: {
|
||
copy0: createProps,
|
||
},
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode copy failed");
|
||
}
|
||
const created = result[1].created?.copy0;
|
||
if (!created) {
|
||
const err = result[1].notCreated?.copy0;
|
||
throw new Error(err?.description || "Failed to copy file node");
|
||
}
|
||
return created as FileNode;
|
||
}
|
||
|
||
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
|
||
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<JMAPClient>();
|
||
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<string, string> = {
|
||
'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<Uint8Array>, controller: AbortController): Promise<void> {
|
||
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:<id>` / `eml:<id>`) 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<void> {
|
||
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<void> {
|
||
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<string, Record<string, string>> = {};
|
||
|
||
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<ArrayBuffer> {
|
||
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<string> {
|
||
// 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<string, boolean>,
|
||
keywords?: Record<string, boolean>,
|
||
accountId?: string,
|
||
): Promise<string> {
|
||
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<void> {
|
||
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<SendEmailResult> {
|
||
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, unknown>, 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<string, { description?: string; type?: string }> };
|
||
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<string, { id?: string }> }).created?.['raw-import']?.id;
|
||
}
|
||
if (methodName === 'EmailSubmission/set') {
|
||
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).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<SendEmailResult> {
|
||
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, unknown>, 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<string, { description?: string; type?: string }> };
|
||
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<string, { id?: string; sendAt?: string }> }).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<string, number>; 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<string, string>();
|
||
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<string, number> = {};
|
||
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<string, string[]>();
|
||
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<string, Email>();
|
||
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<void> {
|
||
// 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<SendEmailResult> {
|
||
// 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/<id> 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<void> {
|
||
const update: Record<string, unknown> = {
|
||
...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<PushSubscription[]> {
|
||
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<string, unknown> = { 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<string, EmailPushConfig>;
|
||
}): Promise<string> {
|
||
const created: Record<string, unknown> = {
|
||
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<void> {
|
||
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<string, unknown> } | 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<string, EmailPushConfig> | null },
|
||
): Promise<boolean> {
|
||
const withEmailPush = patch.emailPush !== undefined && this.hasEmailPushCapability();
|
||
const update: Record<string, unknown> = { ...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<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
|
||
if (r?.notUpdated?.[id]) return false;
|
||
return r?.updated?.[id] !== undefined;
|
||
}
|
||
|
||
async destroyPushSubscription(id: string): Promise<void> {
|
||
await this.request(
|
||
[['PushSubscription/set', { destroy: [id] }, '0']],
|
||
['urn:ietf:params:jmap:core'],
|
||
);
|
||
}
|
||
}
|