mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Fixes from a second review of the Stalwart 1.0 calendar work.
- Changing the alerts of one occurrence of a series that uses the
calendar's default alerts said "Event updated" and kept the default
on 1.0: while a series uses them, 1.0 shows the defaults in place of
an occurrence's own alerts and refuses `useDefaultAlerts` on a single
occurrence. The series now stops using the defaults first, keeping
the alerts it shows under ids of their own, and then the occurrence's
alerts go out. 0.16.23 showed the edit already and still does.
- The participant limit counts the organizer entry a save adds (live on
1.0: 19 attendees and the organizer are accepted, 20 are refused), so
the editor warned one attendee late. It warns at the limit now, and
the warning and the refusal say the organizer counts, with plural
forms in every locale.
- Adding or answering an invitation from the mail view, and applying a
proposal, said "could not complete" when the server refused the
event: the import swallowed its SetError. The banner now asks the
import to reject with the refusal and names the participant limit
like the calendar does.
- A range load that finished after a later one could put back its own
events, range or failure. Only the latest load writes the store, and
a load for an account the user left is dropped.
- A search filter with an OR, a NOT or a condition of several keys next
to its date bounds goes out as it is instead of being expanded, which
1.0 refuses.
- A time zone setting typed in another letter case ("europe/berlin")
is used as Intl spells it. 1.0 refused it as a query argument, and
0.16 and 1.0 both dropped it from new events, which were stored
floating.
- A failed save no longer shows the store's English toast next to the
translated message.
- Tests for a limited read that asks later chunks of a request again
after a refused chunk or a full page.
10602 lines
426 KiB
TypeScript
10602 lines
426 KiB
TypeScript
import { generateUUID } from '@/lib/utils';
|
||
import type { Attachment, 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, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||
import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
|
||
import { textConditions, textFilter } from "./search-utils";
|
||
import { batched, batchedBySize, itemsPerRequest } from "./request-limits";
|
||
import { keywordPointer } from "./patch-pointer";
|
||
import { FirstTouchGate } from "./first-touch-gate";
|
||
import { debug } from "@/lib/debug";
|
||
import { normalizeCalendarEventLike, parseDurationSeconds } 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 { fileNodeKind } from "./filenode-kind";
|
||
import { fileNodeDepth } from "./filenode-depth";
|
||
import { contactFromWire, contactToWire } from "./contact-wire";
|
||
import { SchedulingDeniedError } from "./scheduling-error";
|
||
import { jmapError, jmapErrorType, jmapSetError, type JmapErrorInfo } from "./jmap-error";
|
||
import { isServerSetKeyword } from "./server-keywords";
|
||
|
||
export { SchedulingDeniedError };
|
||
import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules";
|
||
import { folderDepthError } from "@/lib/file-errors";
|
||
import { mediaTypeEssence, OCTET_STREAM } from "@/lib/media-type";
|
||
import { canonicalTimeZone, getEffectiveTimeZone, moveLocalDateTime, toLocalDateTime } from "@/lib/timezone";
|
||
import { buildEmailSort, compareEmails, hasKeywordLevels, hasTextLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
|
||
import type { AccountInfo } from "./types";
|
||
import { stalwartChangeId } from "./stalwart-state";
|
||
|
||
// 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 };
|
||
}
|
||
|
||
type LegacyFileNodeRights = { mayRead?: boolean; mayWrite?: boolean; mayShare?: boolean };
|
||
|
||
/**
|
||
* Stalwart before 0.16.6 implements an older JMAP File Storage draft whose
|
||
* rights are just mayRead / mayWrite / mayShare. Spread mayWrite over the
|
||
* finer rights the UI checks.
|
||
*/
|
||
function fromLegacyRights(rights: FileNodeRights | LegacyFileNodeRights | undefined): FileNodeRights | undefined {
|
||
if (!rights || !('mayWrite' in rights)) return rights as FileNodeRights | undefined;
|
||
const write = !!rights.mayWrite;
|
||
return {
|
||
mayRead: !!rights.mayRead,
|
||
mayAddChildren: write,
|
||
mayRename: write,
|
||
mayDelete: write,
|
||
mayModifyContent: write,
|
||
mayShare: !!rights.mayShare,
|
||
};
|
||
}
|
||
|
||
function toLegacyRights(rights: FileNodeRights): LegacyFileNodeRights {
|
||
return {
|
||
mayRead: rights.mayRead,
|
||
mayWrite: rights.mayAddChildren || rights.mayRename || rights.mayDelete || rights.mayModifyContent,
|
||
mayShare: rights.mayShare,
|
||
};
|
||
}
|
||
|
||
/** A FileNode as the server sent it, normalized for the UI. */
|
||
function fromWireFileNode(node: FileNode): FileNode {
|
||
const decoded = withDecodedName(node);
|
||
if (!decoded.myRights && !decoded.shareWith) return decoded;
|
||
return {
|
||
...decoded,
|
||
myRights: fromLegacyRights(decoded.myRights),
|
||
shareWith: decoded.shareWith
|
||
? Object.fromEntries(Object.entries(decoded.shareWith).map(([p, r]) => [p, fromLegacyRights(r) as FileNodeRights]))
|
||
: decoded.shareWith,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A folder Stalwart 1.0 lists only as the way down to something shared
|
||
* with the user: the ancestors of a shared node come back with a name and
|
||
* parent but no dates and every right false. A node the user may read always
|
||
* has both dates, and 0.16 never leaves `modified` out.
|
||
*/
|
||
function isDiscoverableOnly(node: FileNode): boolean {
|
||
const rights = node.myRights;
|
||
return node.modified == null && node.created == null
|
||
&& !!rights && Object.values(rights).every(granted => !granted);
|
||
}
|
||
|
||
/**
|
||
* RFC 7617 Basic credentials, UTF-8 encoded (what Stalwart decodes). `btoa`
|
||
* alone is Latin-1: it garbles `ü` and throws on anything past U+00FF, such
|
||
* as an unconverted IDN login or a password with `€` in it.
|
||
*/
|
||
function basicAuthHeader(username: string, password: string): string {
|
||
const bytes = new TextEncoder().encode(`${username}:${password}`);
|
||
let binary = '';
|
||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||
return `Basic ${btoa(binary)}`;
|
||
}
|
||
|
||
/**
|
||
* 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';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The server refused a new EmailSubmission with `overQuota`, and removing the
|
||
* records that can no longer be cancelled did not make room. Stalwart 1.0
|
||
* counts every record, whatever its status or age, against `maxSubmissions`
|
||
* (500 by default) - not to be confused with Email/set's `overQuota`, which
|
||
* means the mailbox storage is full.
|
||
*/
|
||
export class SubmissionQuotaError extends Error {
|
||
readonly jmapType = 'overQuota';
|
||
constructor(
|
||
/** The server's own description, if it gave one. */
|
||
readonly description?: string,
|
||
/**
|
||
* The submission account is on Stalwart (`urn:stalwart:jmap`), whose
|
||
* limit is the count of submission records. Another server's overQuota
|
||
* may stand for any sending limit.
|
||
*/
|
||
readonly countsRecords = false,
|
||
) {
|
||
super(description || 'There are too many email submissions');
|
||
this.name = 'SubmissionQuotaError';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "Send now" or "Reschedule" found the original submission no longer pending
|
||
* after making room for its replacement, which can take a while: it went out
|
||
* (`sent`), or was cancelled or removed elsewhere. No replacement was made.
|
||
*/
|
||
export class NoLongerScheduledError extends Error {
|
||
constructor(readonly sent: boolean) {
|
||
super(sent ? 'The scheduled message was already sent' : 'The message is no longer scheduled');
|
||
this.name = 'NoLongerScheduledError';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* How often the background prune may sweep one account's submission records
|
||
* (see JMAPClient.pruneEmailSubmissions).
|
||
*/
|
||
const SUBMISSION_PRUNE_INTERVAL_MS = 10 * 60 * 1000;
|
||
/**
|
||
* How long an account where making room found nothing that could go is not
|
||
* searched again; see makeRoomForSubmission.
|
||
*/
|
||
const SUBMISSION_ROOM_RECHECK_MS = 5 * 60 * 1000;
|
||
/** Upper bound on the pages one prune or change-log walk reads. */
|
||
const SUBMISSION_SCAN_MAX_PAGES = 40;
|
||
|
||
/**
|
||
* 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",
|
||
// No "attachments" here: Stalwart answers it (like bodyStructure/textBody)
|
||
// by reading and parsing every message's full raw blob, so a 300-row page of
|
||
// mail with large files took seconds instead of milliseconds (#1089). List
|
||
// rows fetch their attachment chips lazily via getEmailAttachments.
|
||
// Needed so list rows can serve drag-out to the file system as .eml.
|
||
// Served from stored metadata, so it costs nothing.
|
||
"blobId",
|
||
] as const;
|
||
|
||
// Body-part fields an attachment chip needs (see components/email/attachment-chips.tsx).
|
||
const LIST_ATTACHMENT_BODY_PROPERTIES = [
|
||
"partId", "blobId", "size", "name", "type", "charset", "cid", "disposition",
|
||
] 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;
|
||
|
||
// The Principal properties Bulwark reads. 0.16's default list is the same;
|
||
// 1.0's adds `capabilities` for every principal and `accounts`, a whole
|
||
// account object with every account capability for the user, their groups
|
||
// and sharers.
|
||
const PRINCIPAL_PROPERTIES = ["id", "type", "name", "description", "email"] as const;
|
||
|
||
// The Mailbox properties the folder list reads (RFC 8621 §2), which is also
|
||
// the default list on 0.16 and 1.0. Stalwart 1.0 advertises JMAP Metadata
|
||
// but its handlers are still `todo!()`: a get that selects `metadata` or
|
||
// `privateMetadata` panics on the server. An explicit list keeps them out
|
||
// even if a later release adds them to the default.
|
||
const MAILBOX_PROPERTIES = [
|
||
"id",
|
||
"name",
|
||
"parentId",
|
||
"role",
|
||
"sortOrder",
|
||
"totalEmails",
|
||
"unreadEmails",
|
||
"totalThreads",
|
||
"unreadThreads",
|
||
"myRights",
|
||
"isSubscribed",
|
||
] as const;
|
||
|
||
// SieveScript properties (RFC 9661 §2), explicit for the same reason.
|
||
const SIEVE_SCRIPT_PROPERTIES = ["id", "name", "blobId", "isActive"] as const;
|
||
|
||
// How Stalwart 1.0 words its refusal of Principal/query while the
|
||
// administrator has directory queries off (jmap/src/principal/query.rs). A
|
||
// role without the permission gets `forbidden` too, as "You are not
|
||
// authorized to perform this action", so only the wording tells the two
|
||
// apart; it picks the explanation shown, never what Bulwark does.
|
||
const DIRECTORY_QUERIES_OFF = "The administrator has disabled directory queries.";
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
// Stalwart 1.0 fails a CalendarEvent/get that asks for `recurrenceOverrides`
|
||
// together with `utcStart` or `utcEnd` (invalidArguments, before any id is
|
||
// read), so no property list may hold both. Where a caller needs the
|
||
// overrides, fetchCalendarEventsById reads them with a get of their own.
|
||
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',
|
||
'excludedRecurrenceRule',
|
||
'useDefaultAlerts',
|
||
'alerts',
|
||
'locations',
|
||
'virtualLocations',
|
||
'links',
|
||
'relatedTo',
|
||
'isDraft',
|
||
'isOrigin',
|
||
'baseEventId',
|
||
] as const;
|
||
|
||
/** The second get of fetchCalendarEventsById: the overrides, by id. */
|
||
const OVERRIDE_PROPERTIES = ['id', 'recurrenceOverrides'] as const;
|
||
|
||
// getCalendarEvents' callers (import de-duplication, clearing a calendar,
|
||
// iCal subscription refresh) match events by uid and calendar only; the task
|
||
// keys let isTaskObject keep tasks out.
|
||
const CALENDAR_EVENT_IDENTITY_PROPERTIES = [
|
||
'id',
|
||
'@type',
|
||
'uid',
|
||
'calendarIds',
|
||
'due',
|
||
'progress',
|
||
'percentComplete',
|
||
] as const;
|
||
|
||
// Task-specific properties for CalendarEvent/get when fetching Task objects.
|
||
// No utcStart/utcEnd: the task UI works from `due`, and asking for them next
|
||
// to recurrenceOverrides fails the get on Stalwart 1.0.
|
||
const CALENDAR_TASK_PROPERTIES = [
|
||
'id',
|
||
'@type',
|
||
'uid',
|
||
'calendarIds',
|
||
'title',
|
||
'description',
|
||
'descriptionContentType',
|
||
'created',
|
||
'updated',
|
||
'start',
|
||
'due',
|
||
'duration',
|
||
'timeZone',
|
||
'showWithoutTime',
|
||
'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.
|
||
* A zone the server's time zone database lacks is read as UTC by 0.16 and
|
||
* refused by 1.0, which fails the whole call; JMAPClient.calendarTimeZone
|
||
* decides what is actually sent. The name goes out as Intl spells it: 1.0
|
||
* matches it exactly, so a setting typed "europe/berlin" would read as
|
||
* unknown.
|
||
*/
|
||
function getUserTimeZone(): string | undefined {
|
||
try {
|
||
const zone = getEffectiveTimeZone();
|
||
return (zone && canonicalTimeZone(zone)) || 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;
|
||
|
||
/** RFC 9404; Blob/get needs it in `using`. */
|
||
const BLOB_CAPABILITY = "urn:ietf:params:jmap:blob";
|
||
/**
|
||
* Bytes of blob data one Blob/get in getBlobsAsBase64 asks for. They come
|
||
* back as base64 inside the JSON, a third larger.
|
||
*/
|
||
const BLOB_BATCH_BYTES = 4 * 1024 * 1024;
|
||
/** The largest contact photo Stalwart 1.0 takes by default (maxMediaSize). */
|
||
const BLOB_LARGE_BYTES = 2 * 1024 * 1024;
|
||
|
||
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 };
|
||
}
|
||
|
||
/** "report.pdf" -> "report (2).pdf", the way Stalwart's onExists "rename" names copies. */
|
||
function numberedFileName(name: string, n: number): string {
|
||
const dot = name.lastIndexOf('.');
|
||
return dot > 0 ? `${name.slice(0, dot)} (${n})${name.slice(dot)}` : `${name} (${n})`;
|
||
}
|
||
|
||
/** Throw on a method error of the query that a search request starts with. */
|
||
function assertQuerySucceeded(response: JMAPResponse, what: string): void {
|
||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||
if (name === 'error') {
|
||
throw jmapError(`${what} failed: ${result?.description || result?.type || 'unknown error'}`, result);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The answer to call `callId` of a request. A method error throws with its
|
||
* JMAP type: read as an empty list, a refused calendar get or query used to
|
||
* show an empty calendar instead of a failure.
|
||
*/
|
||
function methodResult(response: JMAPResponse, callId: string, what: string): JMAPResponseResult {
|
||
const entry = response.methodResponses?.find((r) => r[2] === callId);
|
||
if (!entry) throw new Error(`${what} failed: no response`);
|
||
const [name, result] = entry;
|
||
if (name === 'error') {
|
||
throw jmapError(`${what} failed: ${result?.description || result?.type || 'unknown error'}`, result);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Whether an expanded CalendarEvent/query failed on the server's cap on
|
||
* occurrences per query: `cannotCalculateOccurrences` on Stalwart 1.0,
|
||
* `invalidArguments` ("The number of expanded recurrences exceeds the server
|
||
* limit") on 0.16.
|
||
*/
|
||
function isOccurrenceLimitError(error: unknown): boolean {
|
||
const type = jmapErrorType(error);
|
||
if (type === 'cannotCalculateOccurrences') return true;
|
||
return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : '');
|
||
}
|
||
|
||
/**
|
||
* Whether a CalendarEvent get or query failed on its `timeZone` argument:
|
||
* Stalwart 1.0 answers a zone its time zone database lacks with
|
||
* `invalidArguments` "Invalid timeZone \"X\"" (0.16 reads it as UTC).
|
||
*/
|
||
function isTimeZoneRefusal(type: string | undefined, description: string | undefined): boolean {
|
||
return type === 'invalidArguments' && /invalid timeZone/i.test(description ?? '');
|
||
}
|
||
|
||
function isTimeZoneRefusalError(error: unknown): boolean {
|
||
return isTimeZoneRefusal(jmapErrorType(error), error instanceof Error ? error.message : undefined);
|
||
}
|
||
|
||
const DAY_SECONDS = 86_400;
|
||
// Chunks of an expanded range stay a day below maxExpandedQueryDuration: the
|
||
// server measures the span in UTC after resolving the bounds in the request
|
||
// time zone, so a DST change or an ambiguous local time moves it.
|
||
const EXPANDED_CHUNK_SLACK = DAY_SECONDS;
|
||
// A chunk that still expands to too many occurrences is halved down to about
|
||
// a week, one the server finds too long down to an hour; past that, and past
|
||
// this many queries for one range, the range is expanded on the client.
|
||
const MIN_OCCURRENCE_CHUNK = 7 * DAY_SECONDS;
|
||
const MIN_DURATION_CHUNK = 3600;
|
||
const MAX_EXPANDED_CHUNKS = 64;
|
||
// With a limit the chunks are read in the sort order, a few per request:
|
||
// what later chunks return past the limit is dropped.
|
||
const LIMITED_CHUNKS_PER_REQUEST = 4;
|
||
|
||
function throwIfAborted(signal: AbortSignal | undefined): void {
|
||
if (signal?.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||
}
|
||
|
||
function isAbortError(error: unknown): boolean {
|
||
return error instanceof Error && error.name === 'AbortError';
|
||
}
|
||
|
||
/** The range of an expanded CalendarEvent/query, and its filter for other bounds. */
|
||
interface QueryRange {
|
||
after?: string;
|
||
before?: string;
|
||
withBounds: (after?: string, before?: string) => Record<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* The after/before bounds of a CalendarEvent filter, and the filter rebuilt
|
||
* for other bounds. The bounds are keys of the filter itself, or single-key
|
||
* conditions of a top-level AND - how global search sends a range with
|
||
* several text parts. Rebuilt, such an AND stays flat: Stalwart 1.0 expands
|
||
* recurrences only for a single FilterCondition, and reads a condition of
|
||
* two keys as an AND of its own. An AND that holds anything else - an OR, a
|
||
* NOT, a condition of several keys - is no range: 1.0 would refuse to expand
|
||
* it, so it goes out as it is.
|
||
*/
|
||
function rangeOf(filter: CalendarEventFilter): QueryRange {
|
||
if (filter.operator === 'AND' && Array.isArray(filter.conditions)) {
|
||
const plain = (condition: CalendarEventFilter) => !condition.operator && Object.keys(condition).length === 1;
|
||
if (!filter.conditions.every(plain)) return { withBounds: () => ({ ...filter }) };
|
||
let after: string | undefined;
|
||
let before: string | undefined;
|
||
const rest = filter.conditions.filter((condition) => {
|
||
const keys = Object.keys(condition);
|
||
if (keys.length !== 1) return true;
|
||
if (keys[0] === 'after' && typeof condition.after === 'string') {
|
||
after = condition.after;
|
||
return false;
|
||
}
|
||
if (keys[0] === 'before' && typeof condition.before === 'string') {
|
||
before = condition.before;
|
||
return false;
|
||
}
|
||
return true;
|
||
});
|
||
return {
|
||
after,
|
||
before,
|
||
withBounds: (from, to) => ({
|
||
operator: 'AND',
|
||
conditions: [...(from ? [{ after: from }] : []), ...(to ? [{ before: to }] : []), ...rest],
|
||
}),
|
||
};
|
||
}
|
||
const { after, before, ...rest } = filter;
|
||
return {
|
||
after,
|
||
before,
|
||
withBounds: (from, to) => ({ ...rest, ...(from ? { after: from } : {}), ...(to ? { before: to } : {}) }),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Part of an expanded CalendarEvent/query range, in seconds of the request
|
||
* time zone's wall clock (the LocalDateTime bounds counted as if UTC).
|
||
*/
|
||
interface RangeChunk {
|
||
start: number;
|
||
end: number;
|
||
/** Where the next page of this chunk starts. */
|
||
position: number;
|
||
}
|
||
|
||
const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/;
|
||
|
||
/** A LocalDateTime such as "2026-09-01T00:00:00" as wall-clock seconds, or null. */
|
||
function wallClockSeconds(value: unknown): number | null {
|
||
const match = typeof value === 'string' ? LOCAL_DATE_TIME.exec(value) : null;
|
||
if (!match) return null;
|
||
const [year, month, day, hour, minute, second] = match.slice(1).map(Number);
|
||
const date = new Date(0);
|
||
date.setUTCFullYear(year, month - 1, day);
|
||
date.setUTCHours(hour, minute, second, 0);
|
||
return date.getTime() / 1000;
|
||
}
|
||
|
||
function localDateTimeOf(seconds: number): string {
|
||
const date = new Date(seconds * 1000);
|
||
const pad = (n: number, length = 2) => String(n).padStart(length, '0');
|
||
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
|
||
+ `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`;
|
||
}
|
||
|
||
/**
|
||
* [start, end] cut into chunks of at most `span` seconds, or null when that
|
||
* takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server
|
||
* counts an occurrence in a range only when it overlaps it strictly, so one
|
||
* that sits exactly on a border would belong to neither chunk.
|
||
*/
|
||
function chunkRange(start: number, end: number, span: number): RangeChunk[] | null {
|
||
if (span < 2) return null;
|
||
const chunks: RangeChunk[] = [];
|
||
for (let from = start; chunks.length < MAX_EXPANDED_CHUNKS; from = chunks[chunks.length - 1].end - 1) {
|
||
const to = Math.min(from + span, end);
|
||
chunks.push({ start: from, end: to, position: 0 });
|
||
if (to >= end) return chunks;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* What to query instead of a chunk the server refused: its two halves, or
|
||
* null when it is already as small as it gets and the range has to be
|
||
* expanded on the client. Any other refusal is rethrown.
|
||
*/
|
||
function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | null {
|
||
const length = chunk.end - chunk.start;
|
||
let minimum: number;
|
||
if (jmapErrorType(error) === 'expandDurationTooLarge') minimum = MIN_DURATION_CHUNK;
|
||
else if (isOccurrenceLimitError(error)) minimum = MIN_OCCURRENCE_CHUNK;
|
||
else throw error;
|
||
if (length <= minimum) return null;
|
||
const middle = Math.floor((chunk.start + chunk.end) / 2);
|
||
return [
|
||
{ start: chunk.start, end: middle, position: 0 },
|
||
{ start: middle - 1, end: chunk.end, position: 0 },
|
||
];
|
||
}
|
||
|
||
/** The busy periods of a Principal/getAvailability response. */
|
||
function busyPeriodsOf(result: JMAPResponseResult | undefined): BusyPeriod[] {
|
||
const list = (result?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: BusyPeriod['busyStatus'] }>;
|
||
return list.map((p) => ({ utcStart: p.utcStart, utcEnd: p.utcEnd, busyStatus: p.busyStatus ?? null }));
|
||
}
|
||
|
||
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
|
||
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
|
||
}
|
||
|
||
/** A SetError (RFC 8620 §5.3) as returned in notCreated / notUpdated / notDestroyed. */
|
||
interface JMAPSetError {
|
||
type?: string;
|
||
description?: string;
|
||
properties?: string[];
|
||
}
|
||
|
||
/** The SetError an EmailSubmission/set create came back with, if it failed that way. */
|
||
function submissionCreateError(response: JMAPResponse, creationId: string): JMAPSetError | undefined {
|
||
return response.methodResponses
|
||
?.find(([name]) => name === 'EmailSubmission/set')?.[1]?.notCreated?.[creationId];
|
||
}
|
||
|
||
function submissionDestroyCall(ids: string[], accountId: string, callId: string): JMAPMethodCall {
|
||
return ['EmailSubmission/set', { accountId, destroy: ids }, callId];
|
||
}
|
||
|
||
/**
|
||
* The ids an EmailSubmission/set destroy removed. `notFound` means the record
|
||
* is gone already - another tab or device got there first - which is what the
|
||
* caller wanted. Any other failure is only logged: a leftover record costs
|
||
* quota, never mail.
|
||
*/
|
||
function readSubmissionDestroy(response: JMAPResponse, callId: string): string[] {
|
||
const [name, result] = response.methodResponses?.find(([, , id]) => id === callId) ?? [];
|
||
if (name !== 'EmailSubmission/set') {
|
||
console.warn('[EmailSubmission] destroy failed:', result);
|
||
return [];
|
||
}
|
||
const failures = Object.entries((result?.notDestroyed ?? {}) as Record<string, JMAPSetError>)
|
||
.filter(([, error]) => error?.type !== 'notFound');
|
||
if (failures.length > 0) {
|
||
console.warn('[EmailSubmission] not destroyed:', Object.fromEntries(failures));
|
||
}
|
||
return Array.isArray(result?.destroyed) ? result.destroyed as string[] : [];
|
||
}
|
||
|
||
/**
|
||
* The submissions that can no longer be cancelled, going by the undoStatus
|
||
* EmailSubmission/get reports: canceled ones, and final ones whose sendAt has
|
||
* passed. Stalwart computes that status from its queue on every version, so
|
||
* it is the one to trust - unlike the status a create returns (0.16.10-0.16.16
|
||
* say final for a held send) or the query's undoStatus filter (0.16.6-0.16.16
|
||
* match the stored status). A pending submission is never among them, whatever
|
||
* its sendAt: the queue still holds its mail, held or retrying, and without
|
||
* the record it could no longer be cancelled - while destroying it would not
|
||
* stop the delivery either.
|
||
*/
|
||
export function prunableSubmissionIds(
|
||
submissions: Array<Pick<EmailSubmission, 'id' | 'sendAt' | 'undoStatus'>>,
|
||
now: number,
|
||
): string[] {
|
||
return submissions
|
||
.filter((submission) => {
|
||
if (submission.undoStatus === 'canceled') return true;
|
||
if (submission.undoStatus !== 'final' || !submission.sendAt) return false;
|
||
const sendAt = Date.parse(submission.sendAt);
|
||
return Number.isFinite(sendAt) && sendAt <= now;
|
||
})
|
||
.map((submission) => submission.id);
|
||
}
|
||
|
||
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;
|
||
/** When each account's submission records were last pruned (epoch ms). */
|
||
private submissionPrunedAt = new Map<string, number>();
|
||
/** Accounts where making room found nothing to free, until when (epoch ms). */
|
||
private submissionRoomRecheckAt = new Map<string, number>();
|
||
// 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>();
|
||
// Accounts whose server refused a blobId in ContactCard media although the
|
||
// capability suggested it takes one; see supportsContactBlobMedia.
|
||
private contactBlobMediaRejected = 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 = basicAuthHeader(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 [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Attachment parts of the given emails, keyed by email id, for list-row
|
||
* chips. Kept out of the list request on purpose: the server has to read
|
||
* each message's whole raw blob to answer it (#1089), so callers should
|
||
* ask only for the rows that are on screen and have `hasAttachment`.
|
||
* Ids the server does not return are absent from the map.
|
||
*/
|
||
async getEmailAttachments(emailIds: string[], accountId?: string): Promise<Map<string, Attachment[]>> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const result = new Map<string, Attachment[]>();
|
||
for (const batchIds of batched(emailIds, this.getMaxObjectsInGet())) {
|
||
const response = await this.request([
|
||
["Email/get", {
|
||
accountId: targetAccountId,
|
||
ids: batchIds,
|
||
properties: ["id", "attachments"],
|
||
bodyProperties: [...LIST_ATTACHMENT_BODY_PROPERTIES],
|
||
}, "0"],
|
||
]);
|
||
if (response.methodResponses?.[0]?.[0] !== "Email/get") {
|
||
throw new Error("Email/get for list attachments failed");
|
||
}
|
||
const list = (response.methodResponses[0][1]?.list || []) as Pick<Email, "id" | "attachments">[];
|
||
for (const email of list) result.set(email.id, email.attachments ?? []);
|
||
}
|
||
return result;
|
||
}
|
||
/** 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 = basicAuthHeader(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, properties: MAILBOX_PROPERTIES }, "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,
|
||
properties: MAILBOX_PROPERTIES,
|
||
}, "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, properties: MAILBOX_PROPERTIES }, "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, a state that went backwards, 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;
|
||
// Stalwart's change ids only grow. A newState below sinceState means its
|
||
// counters went back - a restore, or an upgrade that kept the old change
|
||
// log but restarted the counters lower (1.0 counts per collection where
|
||
// 0.16 counted per account) - and it then answers an old state with an
|
||
// empty delta, skipping every change since. Take the full fetch instead.
|
||
if (this.hasAccountCapability('urn:stalwart:jmap', targetAccountId)) {
|
||
const since = stalwartChangeId(sinceState);
|
||
const next = stalwartChangeId(r.newState);
|
||
if (since !== null && next !== null && next < since) {
|
||
debug.warn('jmap', `[JMAP] ${type}/changes went back from ${sinceState} to ${r.newState}, falling back to a full fetch`);
|
||
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[];
|
||
if (hasTextLevels(order)) {
|
||
// A sender or subject order is the server's collation, which the
|
||
// client comparator contradicts (Stalwart 1.0 compares byte-order
|
||
// sort keys, empty values last). Keep the query's order: /get may
|
||
// return the objects in any order (RFC 8620 section 5.1).
|
||
const rank = new Map(((queryResponse?.ids ?? []) as string[]).map((id, i) => [id, i]));
|
||
emails.sort((a, b) => (rank.get(a.id) ?? rank.size) - (rank.get(b.id) ?? rank.size));
|
||
} else {
|
||
// 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 1, not 0: Stalwart treats 0 as "no limit" and returns every id.
|
||
limit: 1,
|
||
calculateTotal: true,
|
||
}, `total_${i}`]);
|
||
// Unread count for this tag
|
||
methodCalls.push(["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter: {
|
||
operator: "AND",
|
||
conditions: [
|
||
{ hasKeyword: keyword },
|
||
{ notKeyword: "$seen" },
|
||
],
|
||
},
|
||
// limit 1, not 0: Stalwart treats 0 as "no limit" and returns every id.
|
||
limit: 1,
|
||
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);
|
||
// Stalwart 1.0 sets an attachment keyword on every message; it is not a
|
||
// label anyone applied.
|
||
const keywords = Object.fromEntries(
|
||
Object.entries(scan.keywords).filter(([id]) => !isServerSetKeyword(id)),
|
||
);
|
||
const labels: KeywordInfo[] = Object.entries(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, keywords, 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 1, not 0: Stalwart treats 0 as "no limit" and returns every id.
|
||
limit: 1,
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The keyword writers below check the Email/set outcome: the server refuses
|
||
// a keyword per message in `notUpdated`, not as a request error. Stalwart
|
||
// 1.0 refuses keywords over 128 bytes (invalidProperties) and more than 100
|
||
// keywords on one message (tooManyKeywords); a read-only shared folder
|
||
// refuses any. Unchecked, the UI showed the change until the next reload.
|
||
|
||
async markAsRead(emailId: string, read: boolean = true, accountId?: string): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: {
|
||
[emailId]: {
|
||
"keywords/$seen": read ? true : null,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(response, read ? "mark as read" : "mark as unread");
|
||
}
|
||
|
||
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
|
||
if (emailIds.length === 0) return;
|
||
|
||
const responses: JMAPResponse[] = [];
|
||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||
const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read ? true : null }]));
|
||
responses.push(await this.request([
|
||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||
]));
|
||
}
|
||
// Every batch is sent before a refusal is reported: one refused message
|
||
// must not leave the rest of the selection unchanged.
|
||
for (const response of responses) {
|
||
this.assertEmailSetSucceeded(response, read ? "mark as read" : "mark as unread");
|
||
}
|
||
}
|
||
|
||
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
"keywords/$flagged": starred ? true : null,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(response, starred ? "star the email" : "unstar the email");
|
||
}
|
||
|
||
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>, accountId?: string): Promise<void> {
|
||
// Every value of a full keyword map must be true (RFC 8621 section
|
||
// 4.1.1). Callers start from local copies, where marking a message unread
|
||
// or unstarred leaves `$seen: false` / `$flagged: false` behind.
|
||
const keywordSet = Object.fromEntries(Object.entries(keywords).filter(([, value]) => value === true));
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
keywords: keywordSet,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(response, "update keywords");
|
||
}
|
||
|
||
async setKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
[keywordPointer(keyword)]: true,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(response, `set ${keyword}`);
|
||
}
|
||
|
||
async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
[keywordPointer(keyword)]: null,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(response, `remove ${keyword}`);
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
const responses: JMAPResponse[] = [];
|
||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||
const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
|
||
responses.push(await this.request([
|
||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||
]));
|
||
}
|
||
for (const response of responses) {
|
||
this.assertEmailSetSucceeded(response, "update keywords");
|
||
}
|
||
}
|
||
|
||
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 jmapError(`Failed to ${action}: ${result?.description || result?.type || 'unknown error'}`, result);
|
||
}
|
||
const failed: Record<string, JmapErrorInfo> = {
|
||
...(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 jmapError(
|
||
failedIds.length === 1
|
||
? `Failed to ${action}: ${reason}`
|
||
: `Failed to ${action} ${failedIds.length} email(s); first error: ${reason}`,
|
||
first,
|
||
);
|
||
}
|
||
|
||
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 }])
|
||
);
|
||
|
||
// A refused update leaves the ids unread, and the next query would
|
||
// return them again forever.
|
||
const setResponse = await this.request([
|
||
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(setResponse, "mark the folder as read");
|
||
|
||
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 }])
|
||
);
|
||
const setResponse = await this.request([
|
||
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(setResponse, "mark all as read");
|
||
totalMarked += targetIds.length;
|
||
}
|
||
|
||
hasMore = ids.length === pageSize;
|
||
// Marked messages drop out of the "unread" query, so the next page
|
||
// starts after the ones left unread (excluded folders) only. Advancing
|
||
// by the whole page skipped a page of unread mail every round.
|
||
position += ids.length - targetIds.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;
|
||
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: { [emailId]: patch },
|
||
}, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(response, "mark as spam");
|
||
}
|
||
|
||
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
const response = 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"],
|
||
]);
|
||
this.assertEmailSetSucceeded(response, "mark as not spam");
|
||
}
|
||
|
||
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];
|
||
throw jmapError(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`, err);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
// The JMAP "text" filter searches from, to, cc, bcc, subject and body.
|
||
// Quoted phrases and `word*` prefixes go out as conditions of their
|
||
// own (see splitSearchText). inMailbox joins them in the same AND: a
|
||
// FilterOperator cannot also carry property keys.
|
||
let filter: Record<string, unknown>;
|
||
if (mailboxId) {
|
||
filter = {
|
||
operator: "AND",
|
||
conditions: [
|
||
{ inMailbox: mailboxId },
|
||
...textConditions(query),
|
||
],
|
||
};
|
||
} else {
|
||
filter = textFilter(query);
|
||
}
|
||
|
||
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, properties: ["id", "role"] }, "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');
|
||
}
|
||
|
||
const requestedMailFrom = envelopeMailFrom ? parseRecipientString(envelopeMailFrom).email : undefined;
|
||
let finalIdentityId = identityId;
|
||
let identityReplyTo: EmailAddress[] | undefined;
|
||
let identityEmail: string | 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) {
|
||
// A requested MAIL FROM that is itself one of the account's
|
||
// identities is sent through that identity, so the server accepts
|
||
// it as the envelope sender (#1009).
|
||
let matchingIdentity = requestedMailFrom
|
||
? identities.find((id) => id.email.toLowerCase() === requestedMailFrom.toLowerCase())
|
||
: undefined;
|
||
if (!matchingIdentity && identityId) {
|
||
matchingIdentity = identities.find((id) => id.id === identityId);
|
||
}
|
||
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;
|
||
identityEmail = (matchingIdentity || identities[0]).email;
|
||
}
|
||
}
|
||
}
|
||
// The MAIL FROM the server derives from the identity when the envelope is
|
||
// omitted. A wildcard identity (RFC 8621 §6, "*@example.com") names no
|
||
// concrete address, so the header From stands in for it.
|
||
const identityMailFrom = identityEmail && !identityEmail.includes('*')
|
||
? identityEmail
|
||
: parseRecipientString(fromEmail || this.username).email;
|
||
|
||
// 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,
|
||
},
|
||
};
|
||
|
||
// An explicit envelope is only sent when MAIL FROM should differ from the
|
||
// identity's address (a From override, e.g. replying from a catch-all
|
||
// alias without its own Identity) or carries SMTP parameters. JMAP §7.5:
|
||
// when `envelope` is omitted the server derives mailFrom itself.
|
||
const buildSubmissionCreate = (emailRef: string, mailFrom: string): Record<string, unknown> => {
|
||
const create: Record<string, unknown> = { emailId: emailRef, 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 || mailFrom.toLowerCase() !== identityMailFrom.toLowerCase()) {
|
||
const envelopeRecipients = normalizeEnvelopeRecipients([...to, ...(cc || []), ...(bcc || [])]);
|
||
create.envelope = {
|
||
mailFrom: {
|
||
email: mailFrom,
|
||
...(hasMailFromParams ? { parameters: parameters.mailFrom } : {}),
|
||
},
|
||
rcptTo: hasRcptToParams
|
||
? envelopeRecipients.map((r) => ({ ...r, parameters: parameters.rcptTo }))
|
||
: envelopeRecipients,
|
||
};
|
||
}
|
||
return { "1": create };
|
||
};
|
||
const firstMailFrom = requestedMailFrom || identityMailFrom;
|
||
// The account the submission is 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);
|
||
|
||
// 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: submissionAccountId,
|
||
create: buildSubmissionCreate(`#${emailId}`, firstMailFrom),
|
||
onSuccessUpdateEmail,
|
||
}, "1"]);
|
||
|
||
let response = await this.request(methodCalls);
|
||
|
||
// The message exists in Drafts from here on, sent or not. Two refusals of
|
||
// the submission can be worked around by submitting that copy once more:
|
||
// - overQuota: Stalwart 1.0 counts every submission record against a
|
||
// per-account limit, so remove the ones that can go and try again;
|
||
// - a MAIL FROM other than the identity's own address (RFC 8621 §7.5.1
|
||
// forbiddenMailFrom; Stalwart always refuses it, as forbiddenFrom): use
|
||
// the identity's address - the composer warns about this before sending
|
||
// (#1009).
|
||
// Stalwart checks the quota first, so both can happen, one after the other.
|
||
const draftCopyId = response.methodResponses
|
||
?.find(([name]) => name === 'Email/set')?.[1]?.created?.[emailId]?.id as string | undefined;
|
||
let mailFrom = firstMailFrom;
|
||
let madeRoom = false;
|
||
for (;;) {
|
||
const refusal = submissionCreateError(response, '1');
|
||
if (!draftCopyId || !refusal) break;
|
||
if (refusal.type === 'overQuota' && !madeRoom) {
|
||
madeRoom = true;
|
||
await this.makeRoomForSubmission(submissionAccountId);
|
||
} else if (
|
||
(refusal.type === 'forbiddenMailFrom' || refusal.type === 'forbiddenFrom') &&
|
||
mailFrom.toLowerCase() !== identityMailFrom.toLowerCase()
|
||
) {
|
||
debug.warn('email', `[sendEmail] server refused ${mailFrom} as MAIL FROM (${refusal.type}), sending with ${identityMailFrom}`);
|
||
mailFrom = identityMailFrom;
|
||
} else {
|
||
break;
|
||
}
|
||
const retry = await this.request([["EmailSubmission/set", {
|
||
accountId: submissionAccountId,
|
||
create: buildSubmissionCreate(draftCopyId, mailFrom),
|
||
onSuccessUpdateEmail,
|
||
}, "1"]]);
|
||
response = {
|
||
...retry,
|
||
methodResponses: [
|
||
...response.methodResponses.filter(([name]) => name === 'Email/set'),
|
||
...(retry.methodResponses ?? []),
|
||
],
|
||
};
|
||
}
|
||
|
||
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);
|
||
await this.discardUnsentCopy(draftCopyId, targetAccountId);
|
||
throw jmapError(failure.description || `Failed to send email: ${failure.type}`, failure);
|
||
}
|
||
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),
|
||
);
|
||
// Nothing was sent, so the copy made for this attempt goes; the
|
||
// user's own draft (`draftId`) stays. Each attempt makes a new copy,
|
||
// so keeping them piled up duplicates in Drafts.
|
||
await this.discardUnsentCopy(draftCopyId, targetAccountId);
|
||
this.throwIfHoldTooLong(firstError);
|
||
if (methodName === 'EmailSubmission/set' && firstError?.type === 'overQuota') {
|
||
throw this.submissionQuotaError(firstError.description, submissionAccountId);
|
||
}
|
||
const propsHint = firstError?.properties?.length
|
||
? ` (properties: ${firstError.properties.join(', ')})`
|
||
: '';
|
||
const typeHint = firstError?.type ? ` [${firstError.type}]` : '';
|
||
throw jmapError(
|
||
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
|
||
firstError,
|
||
);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// An immediate send's submission record has done its job once the
|
||
// request that created it returned: the message is queued and filed, and
|
||
// with no hold there is nothing to cancel. Stalwart 1.0 stops accepting
|
||
// submissions once an account holds `maxSubmissions` records, so drop it
|
||
// now. Stalwart's destroy only removes the record, never the queued
|
||
// message; other servers are left to the prune. Decided by the request,
|
||
// not the created undoStatus, which 0.16.10-0.16.16 report as final even
|
||
// for a held send.
|
||
const submissionToDestroy = !delayedUntil && emailSubmissionId &&
|
||
this.hasAccountCapability('urn:stalwart:jmap', submissionAccountId)
|
||
? emailSubmissionId
|
||
: undefined;
|
||
|
||
// 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). The submission
|
||
// record rides along, and nothing about it may turn into a warning.
|
||
if (draftId && createdEmailId) {
|
||
try {
|
||
const cleanupCalls: JMAPMethodCall[] = [
|
||
["Email/set", { accountId: this.accountId, destroy: [draftId] }, "0"],
|
||
];
|
||
if (submissionToDestroy) {
|
||
cleanupCalls.push(submissionDestroyCall([submissionToDestroy], submissionAccountId, "1"));
|
||
}
|
||
const destroyResponse = await this.request(cleanupCalls);
|
||
const destroyResult = destroyResponse.methodResponses?.find(([, , callId]) => callId === "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');
|
||
}
|
||
if (submissionToDestroy) readSubmissionDestroy(destroyResponse, "1");
|
||
} catch (err) {
|
||
console.error('[sendEmail] old draft cleanup failed:', err);
|
||
filingError = filingError ?? 'old draft cleanup failed';
|
||
}
|
||
} else if (submissionToDestroy) {
|
||
await this.destroyEmailSubmissions([submissionToDestroy], submissionAccountId);
|
||
}
|
||
|
||
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 submit = () => this.request([
|
||
["EmailSubmission/set", {
|
||
accountId: targetAccountId,
|
||
create: {
|
||
[subId]: {
|
||
emailId,
|
||
identityId: params.identityId,
|
||
envelope: {
|
||
mailFrom: { email: params.fromEmail },
|
||
rcptTo: [{ email: params.to }],
|
||
},
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
let response = await submit();
|
||
if (submissionCreateError(response, subId)?.type === 'overQuota') {
|
||
await this.makeRoomForSubmission(targetAccountId);
|
||
response = await submit();
|
||
}
|
||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||
const error: JMAPSetError | undefined = name === 'error' ? result : result?.notCreated?.[subId];
|
||
if (error) {
|
||
// Nothing went out: take the receipt back out of Sent, where it would
|
||
// pass for a sent one.
|
||
await this.discardUnsentCopy(emailId, targetAccountId);
|
||
if (name === 'EmailSubmission/set' && error.type === 'overQuota') throw this.submissionQuotaError(error.description, targetAccountId);
|
||
throw jmapError(`MDN submission failed: ${error.description || error.type || 'unknown'}`, error);
|
||
}
|
||
await this.destroyImmediateSubmission(result?.created?.[subId]?.id, targetAccountId);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* The contents of many blobs as base64, through Blob/get (RFC 9404), for an
|
||
* export that needs a lot of them: one download per blob queues behind the
|
||
* server's per-user limits (Stalwart: 4 requests at a time, 1000 a minute),
|
||
* while one Blob/get returns many.
|
||
*
|
||
* More blobs than two of the largest photos would fill are first asked for
|
||
* their sizes, then fetched in requests of about BLOB_BATCH_BYTES, so a
|
||
* response stays bounded however large the photos are.
|
||
*
|
||
* Null when the session doesn't advertise the blob capability, which then
|
||
* must stay out of `using`. Blobs the server doesn't find are left out; once
|
||
* a request fails, the blobs not fetched yet are listed in `failed`.
|
||
*/
|
||
async getBlobsAsBase64(blobIds: string[], accountId?: string): Promise<BlobBatch | null> {
|
||
if (!this.hasCapability(BLOB_CAPABILITY)) return null;
|
||
const account = accountId || this.accountId;
|
||
const ids = [...new Set(blobIds)];
|
||
const data = new Map<string, string>();
|
||
const blobGet = async (chunk: string[], property: "size" | "data:asBase64") => {
|
||
const response = await this.request([
|
||
["Blob/get", { accountId: account, ids: chunk, properties: [property] }, "0"],
|
||
], ["urn:ietf:params:jmap:core", BLOB_CAPABILITY]);
|
||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||
if (name !== "Blob/get") {
|
||
throw jmapError(`Blob/get failed: ${result?.description || result?.type || "unknown"}`, result);
|
||
}
|
||
return (result.list ?? []) as Array<Record<string, unknown>>;
|
||
};
|
||
|
||
let chunks: string[][] = ids.length > 0 ? [ids] : [];
|
||
if (ids.length * BLOB_LARGE_BYTES > BLOB_BATCH_BYTES) {
|
||
const sizes = new Map<string, number | undefined>();
|
||
try {
|
||
for (const chunk of batched(ids, this.getMaxObjectsInGet())) {
|
||
for (const blob of await blobGet(chunk, "size")) {
|
||
if (typeof blob.id === "string") sizes.set(blob.id, typeof blob.size === "number" ? blob.size : undefined);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
debug.warn("jmap", "Blob/get of sizes failed:", error);
|
||
return { data, failed: ids };
|
||
}
|
||
chunks = batchedBySize(ids.filter((id) => sizes.has(id)), (id) => sizes.get(id), BLOB_BATCH_BYTES, this.getMaxObjectsInGet());
|
||
}
|
||
|
||
for (let i = 0; i < chunks.length; i++) {
|
||
try {
|
||
for (const blob of await blobGet(chunks[i], "data:asBase64")) {
|
||
const base64 = blob["data:asBase64"];
|
||
if (typeof blob.id === "string" && typeof base64 === "string") data.set(blob.id, base64);
|
||
}
|
||
} catch (error) {
|
||
debug.warn("jmap", "Blob/get failed:", error);
|
||
return { data, failed: chunks.slice(i).flat() };
|
||
}
|
||
}
|
||
return { data, failed: [] };
|
||
}
|
||
|
||
getCapabilities(): Record<string, unknown> {
|
||
return this.capabilities;
|
||
}
|
||
|
||
hasCapability(capability: string): boolean {
|
||
return capability in this.capabilities;
|
||
}
|
||
|
||
/** The value of an account capability (primary account by default). */
|
||
getAccountCapability(capability: string, accountId?: string): unknown {
|
||
const id = accountId || this.accountId;
|
||
return this.session?.accounts?.[id]?.accountCapabilities?.[capability];
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
getAccountInfo(accountId: string): AccountInfo | null {
|
||
const account = this.accounts[accountId];
|
||
if (!account) return null;
|
||
return {
|
||
isPersonal: account.isPersonal === true,
|
||
isReadOnly: account.isReadOnly === true,
|
||
accountCapabilities: account.accountCapabilities ?? {},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The most participants an event may have, from the account's calendars
|
||
* capability (`maxParticipantsPerEvent`; the primary calendar account's
|
||
* when the account names none), or null when it is not advertised.
|
||
* Stalwart 1.0 refuses to create or update an event with more ATTENDEEs,
|
||
* an answer to an invitation included; 0.16 advertises the same value
|
||
* without enforcing it.
|
||
*/
|
||
getMaxParticipantsPerEvent(accountId?: string): number | null {
|
||
for (const id of [accountId, this.getCalendarsAccountId()]) {
|
||
if (!id) continue;
|
||
const capability = this.getAccountCapability("urn:ietf:params:jmap:calendars", id) as
|
||
{ maxParticipantsPerEvent?: unknown } | undefined;
|
||
const max = capability?.maxParticipantsPerEvent;
|
||
if (typeof max === 'number' && max > 0) return max;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
getMaxSizeAttachmentsPerEmail(): number {
|
||
const mail = this.getAccountCapability("urn:ietf:params:jmap:mail") as { maxSizeAttachmentsPerEmail?: number } | undefined;
|
||
return mail?.maxSizeAttachmentsPerEmail || 0;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
getMaxConcurrentRequests(): number {
|
||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxConcurrentRequests?: number } | undefined;
|
||
return coreCapability?.maxConcurrentRequests || 4;
|
||
}
|
||
|
||
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(),
|
||
properties: SIEVE_SCRIPT_PROPERTIES,
|
||
}, "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;
|
||
}
|
||
|
||
/**
|
||
* Whether ContactCard/set in the account takes a `media` entry that carries
|
||
* a blobId. Stalwart 1.0 embeds contact media as blobs and lists ContactCard
|
||
* among the blob capability's supportedTypeNames (RFC 9404); 0.16 lists only
|
||
* Email, Thread and SieveScript, and refuses such an entry with
|
||
* invalidProperties ("blobIds in media is not supported."). A refusal seen
|
||
* at runtime (rejectContactBlobMedia) wins for the rest of the session.
|
||
*/
|
||
supportsContactBlobMedia(accountId?: string): boolean {
|
||
const id = accountId || this.getContactsAccountId();
|
||
if (this.contactBlobMediaRejected.has(id)) return false;
|
||
const blob = this.getAccountCapability("urn:ietf:params:jmap:blob", id) as { supportedTypeNames?: unknown } | undefined;
|
||
return Array.isArray(blob?.supportedTypeNames) && blob.supportedTypeNames.includes("ContactCard");
|
||
}
|
||
|
||
rejectContactBlobMedia(accountId?: string): void {
|
||
this.contactBlobMediaRejected.add(accountId || this.getContactsAccountId());
|
||
}
|
||
|
||
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,
|
||
options?: { removeContents?: boolean },
|
||
): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
// Without onDestroyRemoveContents a book that still holds cards is
|
||
// refused with `addressBookHasContents` (RFC 9610 §2.3).
|
||
const response = await this.request([
|
||
["AddressBook/set", {
|
||
accountId,
|
||
destroy: [addressBookId],
|
||
...(options?.removeContents ? { onDestroyRemoveContents: true } : {}),
|
||
}, "0"],
|
||
], this.contactUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notDestroyed?.[addressBookId]) {
|
||
const err = result.notDestroyed[addressBookId];
|
||
throw new Error(err.description || "Failed to delete address book");
|
||
}
|
||
}
|
||
|
||
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
|
||
|
||
private principalsUsing(): string[] {
|
||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
|
||
}
|
||
|
||
/**
|
||
* List all principals visible to the user (RFC 9670), and how complete the
|
||
* list is; callers filter client-side. Paged by maxObjectsInGet: an
|
||
* unbounded query hands every id to the chained Principal/get, which the
|
||
* server refuses with `requestTooLarge` once the directory holds more than
|
||
* that (500).
|
||
*
|
||
* Stalwart 1.0 refuses Principal/query with `forbidden` unless the
|
||
* administrator allows directory queries, which is off by default; 0.16
|
||
* does the same for a role without the query permission. The chained get
|
||
* then fails with `invalidResultReference`, so only the query's own error
|
||
* says why. The listing is then `restricted` to what Principal/get without
|
||
* ids still returns: on 1.0 the principals the user knows, on 0.16
|
||
* everyone, unless the role may not read principals either.
|
||
*/
|
||
async listPrincipals(targetAccountId?: string): Promise<PrincipalListing> {
|
||
if (!this.supportsPrincipals()) return { principals: [], status: "ok" };
|
||
const accountId = targetAccountId || this.accountId;
|
||
const pageSize = this.getMaxObjectsInGet();
|
||
const MAX_PRINCIPALS = 20000;
|
||
const all: Principal[] = [];
|
||
try {
|
||
for (let position = 0; position < MAX_PRINCIPALS;) {
|
||
const response = await this.request([
|
||
["Principal/query", { accountId, position, limit: pageSize }, "0"],
|
||
["Principal/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
|
||
properties: PRINCIPAL_PROPERTIES,
|
||
}, "1"],
|
||
], this.principalsUsing());
|
||
|
||
const responses = response.methodResponses ?? [];
|
||
const queryResp = responses.find((r) => r[0] === "Principal/query");
|
||
const getResp = responses.find((r) => r[0] === "Principal/get");
|
||
if (!queryResp || !getResp) {
|
||
const error = responses.find((r) => r[0] === "error")?.[1] as JmapErrorInfo | undefined;
|
||
if (error?.type === "forbidden") {
|
||
return {
|
||
principals: await this.getKnownPrincipals(accountId),
|
||
status: "restricted",
|
||
...(error.description === DIRECTORY_QUERIES_OFF ? { directoryQueriesOff: true } : {}),
|
||
};
|
||
}
|
||
console.error("Failed to fetch principals:", error);
|
||
return { principals: all, status: "error" };
|
||
}
|
||
const ids: string[] = queryResp[1]?.ids ?? [];
|
||
const list = (getResp[1].list || []) as Principal[];
|
||
all.push(...list.map((p) => ({ ...p, accountId })));
|
||
if (ids.length < pageSize) break;
|
||
position += ids.length;
|
||
}
|
||
return { principals: all, status: "ok" };
|
||
} catch (error) {
|
||
console.error("Failed to fetch principals:", error);
|
||
return { principals: all, status: "error" };
|
||
}
|
||
}
|
||
|
||
/** The principals of {@link listPrincipals}, without the status. */
|
||
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
|
||
return (await this.listPrincipals(targetAccountId)).principals;
|
||
}
|
||
|
||
/**
|
||
* Principal/get without ids. When the directory can't be listed, Stalwart
|
||
* 1.0 still returns the principals the user already knows: their own
|
||
* account, their groups and the accounts that shared something with them.
|
||
* Empty when that is refused too.
|
||
*/
|
||
private async getKnownPrincipals(accountId: string): Promise<Principal[]> {
|
||
try {
|
||
const response = await this.request([
|
||
["Principal/get", { accountId, ids: null, properties: PRINCIPAL_PROPERTIES }, "0"],
|
||
], this.principalsUsing());
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "Principal/get") return [];
|
||
return ((result as { list?: Principal[] }).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);
|
||
}
|
||
|
||
/**
|
||
* Whether this user may ask for other people's free/busy. The session
|
||
* advertises free/busy either way, so the answer comes from the user's own
|
||
* principal: Stalwart 1.0 sets `capabilities[calendars].mayGetAvailability`
|
||
* to false while the administrator has directory queries off, and answers
|
||
* every getAvailability with `forbidden` then. 0.16 leaves that object
|
||
* empty, which means go ahead. A `forbidden` answer here (a role without
|
||
* the principal permissions) means no as well; other failures throw.
|
||
*/
|
||
async mayGetAvailability(): Promise<boolean> {
|
||
if (!this.supportsAvailability()) return false;
|
||
const principals = this.getAccountCapability("urn:ietf:params:jmap:principals") as
|
||
{ currentUserPrincipalId?: string | null } | undefined;
|
||
const ownId = principals?.currentUserPrincipalId || this.accountId;
|
||
const response = await this.request([
|
||
["Principal/get", { accountId: this.accountId, ids: [ownId], properties: ["id", "capabilities"] }, "0"],
|
||
], this.principalsUsing());
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "Principal/get") {
|
||
const error = result as JmapErrorInfo | undefined;
|
||
if (error?.type === "forbidden") return false;
|
||
throw jmapError(error?.description || "Failed to read the user's principal", error);
|
||
}
|
||
const own = ((result as { list?: Principal[] }).list ?? []).find((p) => p.id === ownId);
|
||
const calendars = own?.capabilities?.["urn:ietf:params:jmap:calendars"] as { mayGetAvailability?: boolean } | undefined;
|
||
return calendars?.mayGetAvailability !== false;
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* A refusal throws with the JMAP error type: `forbidden` when the server
|
||
* won't answer free/busy for this user, `rateLimit` on Stalwart 1.0 after
|
||
* 100 calls a minute.
|
||
*/
|
||
async getPrincipalAvailability(principalId: string, utcStart: Date, utcEnd: Date): Promise<BusyPeriod[]> {
|
||
if (!this.supportsAvailability()) return [];
|
||
const response = await this.request([
|
||
["Principal/getAvailability", this.availabilityArgs(principalId, utcStart, utcEnd), "0"],
|
||
], [...this.principalsUsing(), JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY]);
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "Principal/getAvailability") {
|
||
const error = result as JmapErrorInfo | undefined;
|
||
throw jmapError(error?.description || "Failed to query availability", error);
|
||
}
|
||
return busyPeriodsOf(result);
|
||
}
|
||
|
||
/**
|
||
* The busy periods of several principals over one window, batched into as
|
||
* few requests as maxCallsInRequest allows: Principal/getAvailability takes
|
||
* a single id. Each principal gets its periods, or the error type its call
|
||
* was refused with. Batching saves round trips only: Stalwart 1.0 counts
|
||
* every call against its limit of 100 a minute. A failed request throws.
|
||
*/
|
||
async getPrincipalsAvailability(principalIds: string[], utcStart: Date, utcEnd: Date): Promise<Record<string, PrincipalAvailability>> {
|
||
const results: Record<string, PrincipalAvailability> = {};
|
||
if (!this.supportsAvailability()) return results;
|
||
for (const batch of batched(principalIds, this.getMaxCallsInRequest())) {
|
||
const response = await this.request(
|
||
batch.map((id, i): JMAPMethodCall => ["Principal/getAvailability", this.availabilityArgs(id, utcStart, utcEnd), String(i)]),
|
||
[...this.principalsUsing(), JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY],
|
||
);
|
||
const byCallId = new Map((response.methodResponses ?? []).map((r) => [r[2], r]));
|
||
batch.forEach((id, i) => {
|
||
const [method, result] = byCallId.get(String(i)) ?? [];
|
||
results[id] = method === "Principal/getAvailability"
|
||
? { periods: busyPeriodsOf(result) }
|
||
: { error: (result as JmapErrorInfo | undefined)?.type || "serverFail" };
|
||
});
|
||
}
|
||
return results;
|
||
}
|
||
|
||
private availabilityArgs(principalId: string, utcStart: Date, utcEnd: Date): Record<string, unknown> {
|
||
return {
|
||
accountId: this.accountId,
|
||
id: principalId,
|
||
utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||
utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||
showDetails: false,
|
||
};
|
||
}
|
||
|
||
// ── 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.map(contactFromWire));
|
||
}
|
||
}
|
||
|
||
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 || []) as ContactCard[];
|
||
return list[0] ? contactFromWire(list[0]) : null;
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
console.error('Failed to get contact:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async createContact(contact: Partial<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 };
|
||
}
|
||
}
|
||
|
||
const contactData = contactToWire(contact, 'create');
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
create: {
|
||
"new-contact": {
|
||
...contactData,
|
||
// Stalwart stores the card without one if omitted (#644)
|
||
uid: contact.uid || `urn:uuid:${generateUUID()}`,
|
||
addressBookIds,
|
||
}
|
||
}
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notCreated?.["new-contact"]) {
|
||
const error = result.notCreated["new-contact"];
|
||
throw jmapError(error.description || "Failed to create contact", error);
|
||
}
|
||
|
||
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();
|
||
|
||
const cleanUpdates = contactToWire(updates, 'update');
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
update: {
|
||
[contactId]: cleanUpdates
|
||
}
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notUpdated?.[contactId]) {
|
||
const error = result.notUpdated[contactId];
|
||
throw jmapError(error.description || "Failed to update contact", error);
|
||
}
|
||
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,
|
||
// Same text rules as mail search (see splitSearchText).
|
||
filter: textFilter(query),
|
||
limit: 50,
|
||
}, "0"],
|
||
["ContactCard/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||
}, "1"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
||
const contacts = rawContacts.map(contactFromWire).map((contact) => ({
|
||
...contact,
|
||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||
originalId: contact.id,
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}));
|
||
allResults.push(...contacts);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to search contacts for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allResults;
|
||
} catch (error) {
|
||
console.error('Failed to search contacts:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calendar/get for one account. The session's first one on the primary
|
||
* calendar account also carries the synthetic-id probe (see
|
||
* supportsSyntheticCalendarIds) and the time zone probe (see
|
||
* calendarTimeZone), so the reads that follow need no round trip of their
|
||
* own for them.
|
||
*/
|
||
private requestCalendars(accountId: string): Promise<JMAPResponse> {
|
||
const methodCalls: JMAPMethodCall[] = [
|
||
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"],
|
||
];
|
||
const isPrimary = accountId === this.getCalendarsAccountId();
|
||
const carriesProbe = !this.syntheticCalendarIdSupport && isPrimary;
|
||
if (carriesProbe) methodCalls.push(this.syntheticIdProbeCall(accountId));
|
||
const timeZone = getUserTimeZone();
|
||
const checksTimeZone = isPrimary && !!timeZone && timeZone !== 'UTC' && !this.timeZoneProbes.has(timeZone);
|
||
if (checksTimeZone) methodCalls.push(this.timeZoneProbeCall(accountId, timeZone));
|
||
const pending = this.request(methodCalls, this.calendarUsing());
|
||
if (carriesProbe) this.rememberSyntheticIdProbe(pending);
|
||
if (checksTimeZone) this.rememberTimeZoneProbe(timeZone, 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 = await this.calendarTimeZone();
|
||
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");
|
||
}
|
||
|
||
/**
|
||
* Every event of the account (or of `calendarIds`), with only the
|
||
* identity properties (CALENDAR_EVENT_IDENTITY_PROPERTIES) filled in.
|
||
*/
|
||
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
return this.withCalendarTimeZone((timeZone) => this.listCalendarEvents(accountId, calendarIds, timeZone));
|
||
}
|
||
|
||
private async listCalendarEvents(accountId: string, calendarIds: string[] | undefined, timeZone: string | undefined): Promise<CalendarEvent[]> {
|
||
// 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());
|
||
const pageIds: string[] = methodResult(queryResponse, "0", "CalendarEvent/query").ids || [];
|
||
ids.push(...pageIds);
|
||
if (pageIds.length < QUERY_PAGE) break;
|
||
position += pageIds.length;
|
||
}
|
||
|
||
if (ids.length === 0) return [];
|
||
|
||
const allEvents = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_EVENT_IDENTITY_PROPERTIES, { timeZone });
|
||
return (allEvents as unknown as CalendarEvent[])
|
||
.filter((event) => !isTaskObject(event))
|
||
.map((event) => normalizeCalendarEventLike(event));
|
||
}
|
||
|
||
/**
|
||
* CalendarEvent/get of `ids` in batches of maxObjectsInGet. With
|
||
* `withOverrides`, each batch also asks for OVERRIDE_PROPERTIES in a second
|
||
* call of the same request, and the overrides are merged in by id: Stalwart
|
||
* 1.0 refuses `recurrenceOverrides` in the same get as `utcStart`/`utcEnd`.
|
||
* A method error on either call fails the whole fetch.
|
||
*/
|
||
private async fetchCalendarEventsById(
|
||
accountId: string,
|
||
ids: string[],
|
||
properties: readonly string[],
|
||
options: { withOverrides?: boolean; timeZone?: string } = {},
|
||
): Promise<Record<string, unknown>[]> {
|
||
const { withOverrides = false, timeZone } = options;
|
||
const objects: Record<string, unknown>[] = [];
|
||
for (const batch of batched(ids, this.getMaxObjectsInGet())) {
|
||
const args = { accountId, ids: batch, ...(timeZone ? { timeZone } : {}) };
|
||
const calls: JMAPMethodCall[] = [["CalendarEvent/get", { ...args, properties: [...properties] }, "0"]];
|
||
if (withOverrides) {
|
||
calls.push(["CalendarEvent/get", { ...args, properties: [...OVERRIDE_PROPERTIES] }, "1"]);
|
||
}
|
||
const response = await this.request(calls, this.calendarUsing());
|
||
const list = (methodResult(response, "0", "CalendarEvent/get").list || []) as Record<string, unknown>[];
|
||
if (withOverrides) {
|
||
const overrides = new Map<unknown, unknown>();
|
||
for (const entry of (methodResult(response, "1", "CalendarEvent/get").list || []) as Record<string, unknown>[]) {
|
||
if ('recurrenceOverrides' in entry) overrides.set(entry.id, entry.recurrenceOverrides);
|
||
}
|
||
for (const object of list) {
|
||
if (overrides.has(object.id)) object.recurrenceOverrides = overrides.get(object.id);
|
||
}
|
||
}
|
||
objects.push(...list);
|
||
}
|
||
return objects;
|
||
}
|
||
|
||
// 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;
|
||
},
|
||
);
|
||
}
|
||
|
||
// Time zones checked with the server (see calendarTimeZone): the probe of
|
||
// each zone, settled or in flight, and the zones the server refused.
|
||
private timeZoneProbes = new Map<string, Promise<void>>();
|
||
private refusedTimeZones = new Set<string>();
|
||
|
||
private static readonly TIME_ZONE_PROBE_CALL_ID = 'time-zone-probe';
|
||
|
||
/** A get of no events, which the server only answers if it knows `timeZone`. */
|
||
private timeZoneProbeCall(accountId: string, timeZone: string): JMAPMethodCall {
|
||
return ["CalendarEvent/get", { accountId, ids: [], properties: ["id"], timeZone }, JMAPClient.TIME_ZONE_PROBE_CALL_ID];
|
||
}
|
||
|
||
/** Derive the verdict on `timeZone` from a response that carried its probe. */
|
||
private rememberTimeZoneProbe(timeZone: string, pending: Promise<JMAPResponse>): void {
|
||
this.timeZoneProbes.set(timeZone, pending.then(
|
||
(response) => {
|
||
const entry = response.methodResponses?.find((r) => r[2] === JMAPClient.TIME_ZONE_PROBE_CALL_ID);
|
||
if (entry?.[0] === 'error' && isTimeZoneRefusal(entry[1]?.type, entry[1]?.description)) {
|
||
this.refuseTimeZone(timeZone);
|
||
}
|
||
},
|
||
// No verdict: the zone goes out, and a refusal is still caught
|
||
// (withCalendarTimeZone).
|
||
() => undefined,
|
||
));
|
||
}
|
||
|
||
private refuseTimeZone(timeZone: string): void {
|
||
if (this.refusedTimeZones.has(timeZone)) return;
|
||
this.refusedTimeZones.add(timeZone);
|
||
console.warn(
|
||
`Calendar: the server does not know the time zone "${timeZone}"; asking it for UTC times instead. `
|
||
+ 'Events without a time zone of their own may show shifted.',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The `timeZone` argument for CalendarEvent/get and /query: the user's zone
|
||
* (getUserTimeZone), or UTC once the server refused it. Stalwart 1.0 fails
|
||
* every calendar get and query that names a zone its time zone database
|
||
* lacks; 0.16 reads such a zone as UTC. The session's first calendar list
|
||
* checks the zone on the way (requestCalendars); a read that starts before
|
||
* that answer, or without one, sends the user's zone and is repeated with
|
||
* UTC if refused (withCalendarTimeZone).
|
||
*/
|
||
private async calendarTimeZone(): Promise<string | undefined> {
|
||
const timeZone = getUserTimeZone();
|
||
if (!timeZone) return undefined;
|
||
await this.timeZoneProbes.get(timeZone);
|
||
return this.refusedTimeZones.has(timeZone) ? 'UTC' : timeZone;
|
||
}
|
||
|
||
/**
|
||
* Run a calendar read with calendarTimeZone, and once more with UTC when
|
||
* the server refuses that zone.
|
||
*/
|
||
private async withCalendarTimeZone<T>(read: (timeZone: string | undefined) => Promise<T>): Promise<T> {
|
||
const timeZone = await this.calendarTimeZone();
|
||
try {
|
||
return await read(timeZone);
|
||
} catch (error) {
|
||
if (!timeZone || timeZone === 'UTC' || !isTimeZoneRefusalError(error)) throw error;
|
||
this.refuseTimeZone(timeZone);
|
||
return read('UTC');
|
||
}
|
||
}
|
||
|
||
async queryAllCalendarEvents(
|
||
filter: CalendarEventFilter,
|
||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||
limit?: number,
|
||
options?: CalendarQueryOptions,
|
||
): Promise<CalendarEvent[]> {
|
||
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];
|
||
throwIfAborted(options?.signal);
|
||
|
||
let events: CalendarEvent[];
|
||
try {
|
||
events = await this.queryCalendarEvents(filter, sort, limit, accountId, options);
|
||
} catch (error) {
|
||
// The user's own calendars failing fails the whole range - an empty
|
||
// answer would read as "no events". A shared account that fails is
|
||
// left out of the result.
|
||
if (isPrimary || isAbortError(error)) throw error;
|
||
console.error(`Failed to query calendar events for account ${accountId}:`, error);
|
||
continue;
|
||
}
|
||
allEvents.push(...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,
|
||
})));
|
||
}
|
||
|
||
return allEvents;
|
||
}
|
||
|
||
async queryCalendarEvents(
|
||
filter: CalendarEventFilter,
|
||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||
limit?: number,
|
||
targetAccountId?: string,
|
||
options?: CalendarQueryOptions,
|
||
): Promise<CalendarEvent[]> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
try {
|
||
return await this.withCalendarTimeZone((timeZone) =>
|
||
this.queryCalendarEventsIn(accountId, filter, sort, limit, timeZone, options?.signal));
|
||
} 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. Anything
|
||
// else, and any failure of the user's own calendars, is the caller's.
|
||
const type = jmapErrorType(error);
|
||
const denied = type === 'forbidden' || type === 'accountNotFound' ||
|
||
/not have access/i.test(error instanceof Error ? error.message : '');
|
||
if (targetAccountId && accountId !== this.getCalendarsAccountId() && denied) {
|
||
this.calendarAccessDenied.add(targetAccountId);
|
||
debug.log('calendar', `No calendar access to account ${targetAccountId} - skipping it from now on`);
|
||
return [];
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/** queryCalendarEvents with `timeZone` as the request's time zone. */
|
||
private async queryCalendarEventsIn(
|
||
accountId: string,
|
||
filter: CalendarEventFilter,
|
||
sort: Array<{ property: string; isAscending: boolean }> | undefined,
|
||
limit: number | undefined,
|
||
timeZone: string | undefined,
|
||
signal?: AbortSignal,
|
||
): Promise<CalendarEvent[]> {
|
||
// Stalwart reads the LocalDateTime after/before filter values in the
|
||
// request's time zone (UTC without one, shifting range boundaries), and
|
||
// drops a `Z` or offset rather than honouring it: UTC bounds become that
|
||
// wall clock. A bound without offset is the user's wall clock (the
|
||
// grid's display dates); it moves along when the server refused the
|
||
// user's zone and the request is in UTC.
|
||
const userTimeZone = getUserTimeZone();
|
||
const bound = (value: string) => toLocalDateTime(
|
||
userTimeZone && timeZone ? moveLocalDateTime(value, userTimeZone, timeZone) : value,
|
||
timeZone,
|
||
);
|
||
const range = rangeOf(filter);
|
||
const after = range.after ? bound(range.after) : undefined;
|
||
const before = range.before ? bound(range.before) : undefined;
|
||
const queryArgs: Record<string, unknown> = { accountId, filter: range.withBounds(after, before) };
|
||
if (timeZone) {
|
||
queryArgs.timeZone = timeZone;
|
||
}
|
||
// Let the server expand recurring series into one synthetic id per
|
||
// occurrence when it also accepts those ids in CalendarEvent/set
|
||
// (Stalwart >= 0.16.20; the server needs both range bounds for it).
|
||
// Older servers keep the client-side expansion in
|
||
// lib/recurrence-expansion.ts, which runs on whatever comes back.
|
||
let expandRecurrences = Boolean(after && before)
|
||
&& await this.supportsSyntheticCalendarIds();
|
||
if (sort) {
|
||
queryArgs.sort = sort;
|
||
}
|
||
|
||
let ids: string[] | null = null;
|
||
if (expandRecurrences) {
|
||
ids = await this.queryExpandedCalendarEventIds(accountId, queryArgs, { ...range, after, before }, limit, signal);
|
||
if (!ids) {
|
||
// The server will not expand this range within its limits. Ask
|
||
// for the series instead; lib/recurrence-expansion.ts expands
|
||
// them on the client.
|
||
debug.log('calendar', 'Range expands past the server limits; expanding on the client instead');
|
||
expandRecurrences = false;
|
||
}
|
||
}
|
||
ids ??= await this.queryCalendarEventIds(queryArgs, limit);
|
||
if (ids.length === 0) return [];
|
||
throwIfAborted(signal);
|
||
|
||
// Only series the client expands itself need their overrides here; a
|
||
// server-expanded occurrence gets them from its base event below.
|
||
const allEvents = await this.fetchCalendarEventsById(accountId, ids, CALENDAR_EVENT_PROPERTIES, {
|
||
withOverrides: !expandRecurrences,
|
||
timeZone,
|
||
});
|
||
const normalized = (allEvents as unknown as CalendarEvent[])
|
||
.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;
|
||
}
|
||
|
||
/**
|
||
* The ids a CalendarEvent/query matches. Without `limit` it pages through
|
||
* the whole result: a busy calendar (or a long expanded range) holds more
|
||
* than one page.
|
||
*/
|
||
private async queryCalendarEventIds(queryArgs: Record<string, unknown>, limit?: number): Promise<string[]> {
|
||
const pageSize = limit || 1000;
|
||
const maxIds = limit || 20000;
|
||
const ids: string[] = [];
|
||
for (let position = 0; ids.length < maxIds;) {
|
||
const response = await this.request([
|
||
["CalendarEvent/query", { ...queryArgs, limit: pageSize, position }, "0"],
|
||
], this.calendarUsing());
|
||
const page: string[] = methodResult(response, "0", "CalendarEvent/query").ids || [];
|
||
ids.push(...page);
|
||
if (limit || page.length < pageSize) break;
|
||
position += page.length;
|
||
}
|
||
return ids;
|
||
}
|
||
/**
|
||
* The longest range an expanded CalendarEvent/query may span, in seconds:
|
||
* `maxExpandedQueryDuration` from the account's calendars capability (the
|
||
* primary calendar account's when the account has none), an iCalendar
|
||
* duration such as "P365D". Every 0.16 advertises 365 days without
|
||
* enforcing it; 1.0 refuses a longer range with `expandDurationTooLarge`.
|
||
*/
|
||
private getMaxExpandedQuerySeconds(accountId: string): number | null {
|
||
for (const id of [accountId, this.getCalendarsAccountId()]) {
|
||
const capability = this.getAccountCapability("urn:ietf:params:jmap:calendars", id) as
|
||
{ maxExpandedQueryDuration?: unknown } | undefined;
|
||
const duration = capability?.maxExpandedQueryDuration;
|
||
const seconds = typeof duration === 'string' ? parseDurationSeconds(duration) : null;
|
||
if (seconds) return seconds;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The occurrence ids of an expanded range query, or null when the server
|
||
* will not expand the range and the client has to.
|
||
*
|
||
* The range is cut into chunks within the server's maxExpandedQueryDuration
|
||
* and the chunk queries go out together, as many per request as
|
||
* maxCallsInRequest allows. A synthetic id does not depend on the range it
|
||
* was found in, so an occurrence two chunks share is kept once. A chunk the
|
||
* server still refuses is halved: when it is too long
|
||
* (`expandDurationTooLarge`), or when it expands to too many occurrences,
|
||
* down to about a week. Past that the whole range is given up - mixing
|
||
* server- and client-expanded occurrences would show series twice.
|
||
*
|
||
* With a limit the chunks are read in the sort order (by start), a few
|
||
* per request, until the limit is reached.
|
||
*/
|
||
private async queryExpandedCalendarEventIds(
|
||
accountId: string,
|
||
queryArgs: Record<string, unknown>,
|
||
range: QueryRange,
|
||
limit?: number,
|
||
signal?: AbortSignal,
|
||
): Promise<string[] | null> {
|
||
const expandedArgs = { ...queryArgs, expandRecurrences: true };
|
||
const start = wallClockSeconds(range.after);
|
||
const end = wallClockSeconds(range.before);
|
||
if (start === null || end === null || end <= start) {
|
||
try {
|
||
return await this.queryCalendarEventIds(expandedArgs, limit);
|
||
} catch (error) {
|
||
if (jmapErrorType(error) === 'expandDurationTooLarge' || isOccurrenceLimitError(error)) return null;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const maxSeconds = this.getMaxExpandedQuerySeconds(accountId);
|
||
const span = maxSeconds === null
|
||
? end - start
|
||
: maxSeconds - Math.min(EXPANDED_CHUNK_SLACK, Math.floor(maxSeconds / 2));
|
||
const chunks = chunkRange(start, end, span);
|
||
if (!chunks) return null;
|
||
const [firstSort] = (queryArgs.sort as Array<{ property?: string; isAscending?: boolean }> | undefined) ?? [];
|
||
const latestFirst = firstSort?.property === 'start' && firstSort.isAscending === false;
|
||
if (latestFirst) chunks.reverse();
|
||
|
||
const pageSize = limit || 1000;
|
||
const maxIds = limit || 20000;
|
||
const ids = new Set<string>();
|
||
let pending = chunks;
|
||
// Pages are bounded by maxIds; splitting is what has to stop somewhere.
|
||
const chunksSent = new Set<RangeChunk>();
|
||
while (pending.length > 0 && ids.size < maxIds) {
|
||
throwIfAborted(signal);
|
||
const batch = pending.splice(0, limit
|
||
? Math.min(LIMITED_CHUNKS_PER_REQUEST, this.getMaxCallsInRequest())
|
||
: this.getMaxCallsInRequest());
|
||
for (const chunk of batch) if (chunk.position === 0) chunksSent.add(chunk);
|
||
if (chunksSent.size > MAX_EXPANDED_CHUNKS) return null;
|
||
const size = limit ? limit - ids.size : pageSize;
|
||
const response = await this.request(batch.map((chunk, i): JMAPMethodCall => ["CalendarEvent/query", {
|
||
...expandedArgs,
|
||
filter: range.withBounds(localDateTimeOf(chunk.start), localDateTimeOf(chunk.end)),
|
||
position: chunk.position,
|
||
limit: size,
|
||
}, String(i)]), this.calendarUsing());
|
||
|
||
// The halves of a refused chunk, and the next page of a full one, go
|
||
// to the front of the queue. With a limit, a chunk that needs another
|
||
// query comes before the rest of its batch in the sort order: what the
|
||
// later chunks returned is dropped and asked for again after it.
|
||
const again: RangeChunk[] = [];
|
||
for (const [i, chunk] of batch.entries()) {
|
||
let page: string[];
|
||
try {
|
||
page = methodResult(response, String(i), "CalendarEvent/query").ids || [];
|
||
} catch (error) {
|
||
const halves = splitRefusedChunk(chunk, error);
|
||
if (!halves) return null;
|
||
again.push(...(latestFirst ? halves.reverse() : halves));
|
||
if (limit) {
|
||
again.push(...batch.slice(i + 1));
|
||
break;
|
||
}
|
||
continue;
|
||
}
|
||
for (const id of page) {
|
||
if (ids.size >= maxIds) break;
|
||
ids.add(id);
|
||
}
|
||
if (ids.size >= maxIds) break;
|
||
if (page.length === size) {
|
||
again.push({ ...chunk, position: chunk.position + page.length });
|
||
if (limit) {
|
||
again.push(...batch.slice(i + 1));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
pending = [...again, ...pending];
|
||
}
|
||
return [...ids];
|
||
}
|
||
|
||
/**
|
||
* 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 base of await this.fetchCalendarEventsById(accountId, baseIds, RECURRENCE_BASE_PROPERTIES, { timeZone })) {
|
||
if (typeof base.id === 'string') bases.set(base.id, normalizeCalendarEventLike(base as Partial<CalendarEvent>));
|
||
}
|
||
return hydrateRecurrenceInstances(events, bases);
|
||
}
|
||
|
||
/** One event with its recurrence overrides, or null when the server has no such id. */
|
||
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const [event] = await this.withCalendarTimeZone((timeZone) =>
|
||
this.fetchCalendarEventsById(accountId, [id], CALENDAR_EVENT_PROPERTIES, { withOverrides: true, timeZone }));
|
||
return event ? normalizeCalendarEventLike(event as unknown as CalendarEvent) : 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();
|
||
if (sendSchedulingMessages && error.type === 'forbidden') {
|
||
throw new SchedulingDeniedError(error.description || 'forbidden');
|
||
}
|
||
throw jmapSetError(error.description || "Failed to create calendar event", error);
|
||
}
|
||
|
||
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) {
|
||
// The event exists from here on, and its invitations are on their
|
||
// way: a failed read-back must not report a failed create, or a
|
||
// retry creates the event twice. Fall back to what was sent.
|
||
let created: CalendarEvent | null = null;
|
||
try {
|
||
created = await this.getCalendarEvent(createdId, targetAccountId);
|
||
} catch (refetchError) {
|
||
debug.warn('calendar', 'CalendarEvent/create could not read the created event back', refetchError);
|
||
}
|
||
if (!created) {
|
||
debug.warn('calendar', 'CalendarEvent/create returns the sent event for the created id', { createdId, targetAccountId });
|
||
created = normalizeCalendarEventLike({ ...cleanEvent, ...result.created["new-event"], id: createdId } as CalendarEvent);
|
||
}
|
||
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,
|
||
});
|
||
debug.groupEnd();
|
||
return created;
|
||
}
|
||
}
|
||
|
||
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 sentById = new Map<string, Record<string, unknown>>();
|
||
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);
|
||
sentById.set(result.created[key].id, { ...createMap[key], ...result.created[key] });
|
||
} 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 };
|
||
}
|
||
|
||
// Read the created events back for their server-assigned properties.
|
||
// They exist whatever the read-back says, so what was sent stands in for
|
||
// any event it does not return.
|
||
let fetched: Record<string, unknown>[] = [];
|
||
try {
|
||
fetched = await this.withCalendarTimeZone((timeZone) =>
|
||
this.fetchCalendarEventsById(accountId, createdIds, CALENDAR_EVENT_PROPERTIES, { timeZone }));
|
||
} catch (error) {
|
||
debug.warn('calendar', 'CalendarEvent/batchCreate could not read the created events back', error);
|
||
}
|
||
const fetchedById = new Map(fetched.map((event) => [event.id, event]));
|
||
const createdEvents = createdIds.map((id) =>
|
||
normalizeCalendarEventLike((fetchedById.get(id) ?? sentById.get(id)) as unknown as CalendarEvent));
|
||
|
||
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,
|
||
options?: CalendarEventUpdateOptions,
|
||
): 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 };
|
||
if (options?.keepSequence && _sq != null) (cleanUpdates as Partial<CalendarEvent>).sequence = _sq;
|
||
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 jmapError(`JMAP error (${errorType}): ${errorDesc}`, result);
|
||
}
|
||
|
||
if (methodName === "CalendarEvent/set") {
|
||
if (result.notUpdated?.[eventId]) {
|
||
const error = result.notUpdated[eventId];
|
||
debug.error('CalendarEvent/set notUpdated', { eventId, error });
|
||
if (sendSchedulingMessages && error.type === 'forbidden') {
|
||
throw new SchedulingDeniedError(error.description || 'forbidden');
|
||
}
|
||
// Keep the SetError type: the store retries some refusals
|
||
// differently (isOverrideSeriesKeyRejection).
|
||
throw jmapSetError(error.description || "Failed to update calendar event", error);
|
||
}
|
||
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 {
|
||
const allObjects = await this.withCalendarTimeZone((timeZone) => this.fetchTaskObjects(accountId, calendarIds, timeZone));
|
||
|
||
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) {
|
||
// A refused query or get is a failure, not an empty task list: the
|
||
// task store keeps showing what it has.
|
||
debug.error('CalendarTask/fetch failed', error);
|
||
debug.groupEnd();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Every object of the account (or of `calendarIds`) with the task
|
||
* properties. Stalwart has no filter for tasks, so events come too.
|
||
*/
|
||
private async fetchTaskObjects(
|
||
accountId: string,
|
||
calendarIds: string[] | undefined,
|
||
timeZone: string | undefined,
|
||
): Promise<Record<string, unknown>[]> {
|
||
// Page through the query to collect all object ids.
|
||
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 response = await this.request([
|
||
["CalendarEvent/query", queryArgs, "0"],
|
||
], this.calendarUsing());
|
||
const pageIds: string[] = methodResult(response, "0", "CalendarEvent/query").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) {
|
||
return [];
|
||
}
|
||
|
||
// Fetch the objects in batches that respect the server's /get limit.
|
||
return this.fetchCalendarEventsById(accountId, ids, CALENDAR_TASK_PROPERTIES, { timeZone });
|
||
}
|
||
|
||
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());
|
||
|
||
let result: JMAPResponseResult;
|
||
try {
|
||
// A method-level error (unknown calendar, missing capability) means
|
||
// nothing was created.
|
||
result = methodResult(response, "0", "CalendarEvent/set");
|
||
} catch (error) {
|
||
debug.groupEnd();
|
||
throw error;
|
||
}
|
||
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 jmapSetError(error.description || "Failed to create task", error);
|
||
}
|
||
|
||
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");
|
||
}
|
||
|
||
// Read the task back with the task properties. It exists whatever the
|
||
// read-back says: fall back to what was sent rather than report a failed
|
||
// create, which a retry would duplicate.
|
||
debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
|
||
let fetched: Record<string, unknown> | undefined;
|
||
try {
|
||
[fetched] = await this.withCalendarTimeZone((timeZone) =>
|
||
this.fetchCalendarEventsById(accountId, [createdId], CALENDAR_TASK_PROPERTIES, { timeZone }));
|
||
} catch (error) {
|
||
debug.warn('tasks', 'CalendarTask/create could not read the created task back', error);
|
||
}
|
||
if (!fetched) debug.warn('tasks', 'CalendarTask/create returns the sent task for id', createdId);
|
||
const created = normalizeCalendarTask({
|
||
...(fetched ?? { ...cleanTask, ...serverCreated, id: createdId }),
|
||
'@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;
|
||
}
|
||
|
||
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",
|
||
// Tells 1.0's symlinks from folders (see fileNodeKind).
|
||
"nodeType",
|
||
// 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(fromWireFileNode);
|
||
}
|
||
|
||
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(fromWireFileNode);
|
||
}
|
||
|
||
/**
|
||
* Every raw FileNode of one account, past the server's /get ceiling.
|
||
*
|
||
* `FileNode/get { ids: null }` stops at maxObjectsInGet (500 by default) and
|
||
* Stalwart gives no sign that the list was cut, so larger accounts silently
|
||
* lost files (#1069). A full first page therefore counts as truncated: the
|
||
* remaining ids come from paging FileNode/query and are fetched in /get-sized
|
||
* batches. Stalwart before 0.16.6 leaves folders out of FileNode/query, so
|
||
* parents that are still unknown afterwards are fetched by id.
|
||
*/
|
||
private async fetchAllFileNodes(accountId: string): Promise<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. Folders listed only as the way down to a shared
|
||
* node are marked `discoverableOnly`. 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) {
|
||
const wire = fromWireFileNode(node);
|
||
all.push({
|
||
...wire,
|
||
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,
|
||
...(!isPrimary && isDiscoverableOnly(wire) ? { discoverableOnly: true } : {}),
|
||
});
|
||
}
|
||
} 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 node = targetAccountId
|
||
? { accountId: targetAccountId, id: fileNodeId }
|
||
: this.resolveFileNodeId(fileNodeId);
|
||
const accountId = node.accountId;
|
||
const rawId = node.id as string;
|
||
const wireRights = rights && this.isLegacyFileNodeServer(accountId) ? toLegacyRights(rights) : rights;
|
||
const response = await this.request([
|
||
["FileNode/set", {
|
||
accountId,
|
||
update: { [rawId]: { [`shareWith/${principalId}`]: wireRights } },
|
||
}, "0"],
|
||
], this.fileUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[rawId]) {
|
||
const err = result.notUpdated[rawId];
|
||
throw new Error(err.description || "Failed to update file share");
|
||
}
|
||
if (!result?.updated || !(rawId in result.updated)) {
|
||
throw new Error("Server did not confirm the share update");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The account and bare id behind a FileNode id. Nodes of shared drives are
|
||
* listed as "<accountId>:<nodeId>" (listAllFileNodesAcrossAccounts; JMAP ids
|
||
* never contain ':'), and a write must go to that account with the bare id.
|
||
* `null` is the primary account's root.
|
||
*/
|
||
private resolveFileNodeId(id: string | null): { accountId: string; id: string | null } {
|
||
const primary = this.getFilesAccountId();
|
||
if (id === null) return { accountId: primary, id: null };
|
||
const sep = id.indexOf(':');
|
||
if (sep > 0) {
|
||
const accountId = id.slice(0, sep);
|
||
if (accountId !== primary && this.getFilesCapableAccountIds().includes(accountId)) {
|
||
return { accountId, id: id.slice(sep + 1) };
|
||
}
|
||
}
|
||
return { accountId: primary, id };
|
||
}
|
||
|
||
private fileNodeCapability(accountId: string): Record<string, unknown> | undefined {
|
||
return this.getAccountCapability("urn:ietf:params:jmap:filenode", accountId) as Record<string, unknown> | undefined;
|
||
}
|
||
|
||
/**
|
||
* The filenode capability whose limits apply in `accountId`. Stalwart
|
||
* publishes the same ones for every account, and a shared account may
|
||
* come without any (see getFilesCapableAccountIds), so the user's own
|
||
* files account stands in.
|
||
*/
|
||
private fileNodeLimits(accountId?: string): Record<string, unknown> | undefined {
|
||
const own = this.getFilesAccountId();
|
||
return this.fileNodeCapability(accountId || own) ?? this.fileNodeCapability(own);
|
||
}
|
||
|
||
/**
|
||
* Stalwart before 0.16.6 implements an older File Storage draft (old
|
||
* rights names, 30-character MIME types, no naming rules). The draft update
|
||
* added `forbiddenNameChars` to the capability, which tells them apart.
|
||
*/
|
||
private isLegacyFileNodeServer(accountId: string): boolean {
|
||
const cap = this.fileNodeCapability(accountId);
|
||
return !!cap && !("forbiddenNameChars" in cap);
|
||
}
|
||
|
||
/** Naming rules for new FileNodes in `accountId`, if the server publishes any. */
|
||
getFileNameRules(accountId?: string): FileNameRules | null {
|
||
return fileNameRulesFrom(this.fileNodeLimits(accountId));
|
||
}
|
||
|
||
/**
|
||
* How deep a node may sit (`maxFileNodeDepth`: 64 on Stalwart 1.0), or
|
||
* null for no limit (0.16 advertises null).
|
||
*/
|
||
getMaxFileNodeDepth(accountId?: string): number | null {
|
||
const depth = this.fileNodeLimits(accountId)?.maxFileNodeDepth;
|
||
return typeof depth === 'number' && depth > 0 ? depth : null;
|
||
}
|
||
|
||
/** The `type` to create a file node in `accountId` with. */
|
||
private fileNodeType(accountId: string, type: string | null | undefined): string {
|
||
// Stalwart 1.0 only takes a bare, valid media type; servers before
|
||
// 0.16.6 refuse more than 30 characters (most OOXML types).
|
||
const essence = mediaTypeEssence(type);
|
||
return this.isLegacyFileNodeServer(accountId) && essence.length > 30 ? OCTET_STREAM : essence;
|
||
}
|
||
|
||
/** Namespace a node created in `accountId` the way the listings do. */
|
||
private namespaceFileNode(node: FileNode, accountId: string): FileNode {
|
||
if (accountId === this.getFilesAccountId()) return node;
|
||
return {
|
||
...node,
|
||
id: `${accountId}:${node.id}`,
|
||
parentId: node.parentId == null ? null : `${accountId}:${node.parentId}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Resolve a parent for a node that lives in `accountId`. Folders only hold
|
||
* nodes of their own account, so a parent in another account is refused
|
||
* instead of being sent (the server would read it as a local id).
|
||
*/
|
||
private parentInAccount(parentId: string | null, accountId: string): string | null {
|
||
const parent = this.resolveFileNodeId(parentId);
|
||
if (parent.accountId !== accountId) {
|
||
throw new Error('Files cannot be moved between accounts');
|
||
}
|
||
return parent.id;
|
||
}
|
||
|
||
/**
|
||
* FileNode/set `create` of one node. `onExists: "rename"` (Stalwart 0.16.6+,
|
||
* ignored before) turns a name clash into "name (2)" instead of a failure.
|
||
*/
|
||
private async createFileNodeIn(
|
||
accountId: string,
|
||
props: Record<string, unknown>,
|
||
what: string,
|
||
): Promise<FileNode> {
|
||
// Names from the local file system (uploads) may hold characters the
|
||
// server refuses; store an accepted variant instead of failing the batch.
|
||
const baseName = acceptedFileName(String(props.name ?? ''), this.getFileNameRules(accountId));
|
||
for (let attempt = 1; ; attempt++) {
|
||
const name = attempt === 1 ? baseName : numberedFileName(baseName, attempt);
|
||
const response = await this.request(
|
||
[["FileNode/set", { accountId, onExists: "rename", create: { n0: { ...props, name } } }, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw jmapError(result?.[1]?.description || "FileNode/set create failed", result?.[1]);
|
||
}
|
||
const created = result[1].created?.n0;
|
||
if (created) {
|
||
// The create response carries only server-set properties; keep the
|
||
// ones sent so callers can rely on name/parentId/type.
|
||
return withDecodedName({ ...props, name, ...created } as FileNode);
|
||
}
|
||
const err = result[1].notCreated?.n0;
|
||
// Servers before 0.16.6 ignore onExists and refuse the clash instead.
|
||
if (attempt < 20 && /already exists/i.test(err?.description ?? '')) continue;
|
||
throw jmapError(err?.description || `Failed to create ${what}`, err);
|
||
}
|
||
}
|
||
|
||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||
const parent = this.resolveFileNodeId(parentId);
|
||
|
||
// A FileNode is a folder (container) only when it has no content - i.e. no
|
||
// blobId, type or size, so the server stores it with `file == null`. Sending
|
||
// any of those - as older builds did (type "d" + an empty blob) - makes it a
|
||
// 0-byte FILE that nothing can ever be parented under, which is what caused
|
||
// "Parent ID does not exist or is not a folder" during the #379 migration.
|
||
const dirProps: Record<string, unknown> = { name };
|
||
if (parent.id !== null) {
|
||
dirProps.parentId = parent.id;
|
||
}
|
||
|
||
const created = await this.createFileNodeIn(parent.accountId, dirProps, 'directory');
|
||
return this.namespaceFileNode(created, parent.accountId);
|
||
}
|
||
|
||
/**
|
||
* `size` is not sent: every server works it out from the blob, and
|
||
* Stalwart 1.0 refuses a create whose size differs from the blob's.
|
||
*/
|
||
async createFileNode(name: string, blobId: string, type: string, _size: number, parentId: string | null): Promise<FileNode> {
|
||
const parent = this.resolveFileNodeId(parentId);
|
||
|
||
const fileProps: Record<string, unknown> = { name, type: this.fileNodeType(parent.accountId, type), blobId };
|
||
if (parent.id !== null) {
|
||
fileProps.parentId = parent.id;
|
||
}
|
||
|
||
const created = await this.createFileNodeIn(parent.accountId, fileProps, 'file node');
|
||
return this.namespaceFileNode(created, parent.accountId);
|
||
}
|
||
|
||
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||
const node = this.resolveFileNodeId(id);
|
||
const patch: Record<string, unknown> = { ...updates };
|
||
if ('parentId' in updates) {
|
||
patch.parentId = this.parentInAccount(updates.parentId ?? null, node.accountId);
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId: node.accountId,
|
||
update: { [node.id as string]: patch },
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw jmapError(result?.[1]?.description || "FileNode/set update failed", result?.[1]);
|
||
}
|
||
const refused = result[1].notUpdated?.[node.id as string];
|
||
if (refused) {
|
||
throw jmapError(refused.description || "Failed to update file node", refused);
|
||
}
|
||
}
|
||
|
||
/** Group FileNode ids by the account that owns them. */
|
||
private groupFileNodeIds<T>(entries: Array<[string, T]>): Map<string, Array<{ id: string; rawId: string; value: T }>> {
|
||
const groups = new Map<string, Array<{ id: string; rawId: string; value: T }>>();
|
||
for (const [id, value] of entries) {
|
||
const node = this.resolveFileNodeId(id);
|
||
const list = groups.get(node.accountId) ?? [];
|
||
list.push({ id, rawId: node.id as string, value });
|
||
groups.set(node.accountId, list);
|
||
}
|
||
return groups;
|
||
}
|
||
|
||
/**
|
||
* Update many FileNodes in a single FileNode/set call. Returns the ids that
|
||
* were updated and a map of id -> error for any the server rejected. Never
|
||
* throws for per-node failures (only for a whole-method error).
|
||
*/
|
||
async updateFileNodes(updates: Record<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 updated: string[] = [];
|
||
const notUpdated: Record<string, string> = {};
|
||
|
||
for (const [accountId, nodes] of this.groupFileNodeIds(entries)) {
|
||
for (const batch of batched(nodes, this.getMaxObjectsInSet())) {
|
||
const byRawId = new Map(batch.map(n => [n.rawId, n.id]));
|
||
const update: Record<string, Record<string, unknown>> = {};
|
||
for (const n of batch) {
|
||
const patch: Record<string, unknown> = { ...n.value };
|
||
if ('parentId' in n.value) {
|
||
try {
|
||
patch.parentId = this.parentInAccount(n.value.parentId ?? null, accountId);
|
||
} catch (error) {
|
||
notUpdated[n.id] = error instanceof Error ? error.message : 'not updated';
|
||
continue;
|
||
}
|
||
}
|
||
update[n.rawId] = patch;
|
||
}
|
||
if (Object.keys(update).length === 0) continue;
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", { accountId, update }, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||
}
|
||
|
||
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||
for (const rawId of Object.keys(notUpdatedMap)) {
|
||
notUpdated[byRawId.get(rawId) ?? rawId] = notUpdatedMap[rawId]?.description || 'not updated';
|
||
}
|
||
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||
const done = Object.keys(updatedMap).length > 0
|
||
? Object.keys(updatedMap).map(rawId => byRawId.get(rawId) ?? rawId)
|
||
: Object.keys(update).map(rawId => byRawId.get(rawId) as string).filter(id => !(id in notUpdated));
|
||
updated.push(...done);
|
||
}
|
||
}
|
||
|
||
return { updated, notUpdated };
|
||
}
|
||
|
||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||
const destroyed: string[] = [];
|
||
|
||
for (const [accountId, nodes] of this.groupFileNodeIds(ids.map(id => [id, null] as [string, null]))) {
|
||
for (const batch of batched(nodes, this.getMaxObjectsInSet())) {
|
||
const byRawId = new Map(batch.map(n => [n.rawId, n.id]));
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
destroy: batch.map(n => n.rawId),
|
||
onDestroyRemoveChildren: true,
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw jmapError(result?.[1]?.description || "FileNode/set destroy failed", result?.[1]);
|
||
}
|
||
|
||
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 jmapError(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`, firstError);
|
||
}
|
||
|
||
destroyed.push(...((result[1].destroyed || []) as string[]).map(rawId => byRawId.get(rawId) ?? rawId));
|
||
}
|
||
}
|
||
|
||
return { destroyed, notDestroyed: [] };
|
||
}
|
||
|
||
/**
|
||
* Copy a file, or a folder with everything in it, into `parentId` (which
|
||
* may be in another account). A folder node carries no content, so creating
|
||
* one "copy" node would only make an empty folder: the subtree is recreated
|
||
* level by level.
|
||
*
|
||
* The copies point at the source's blobs. Stalwart lets anyone who may read
|
||
* a FileNode use its blobId in any account, so this works both ways between
|
||
* drives. Blob/copy reserved the content to the target account, which only
|
||
* that account's members may use: copying into someone else's shared drive
|
||
* failed with it.
|
||
*/
|
||
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||
const source = this.resolveFileNodeId(id);
|
||
const target = this.resolveFileNodeId(parentId);
|
||
|
||
const tree = await this.fetchAllFileNodes(source.accountId);
|
||
const original = tree.find(n => n.id === source.id);
|
||
if (!original) throw new Error('File node not found');
|
||
// Symlinks (Stalwart 1.0) are left out: there is no content to copy, and
|
||
// the blobId rule used to recreate them as empty folders.
|
||
if (fileNodeKind(original) === 'symlink') throw new Error('Symbolic links cannot be copied');
|
||
const isFolderNode = (n: FileNode) => fileNodeKind(n) === 'directory';
|
||
|
||
// The whole subtree is laid out from one listing and checked before the
|
||
// first create.
|
||
const childrenOf = new Map<string, FileNode[]>();
|
||
for (const node of tree) {
|
||
if (node.parentId == null) continue;
|
||
const siblings = childrenOf.get(node.parentId);
|
||
if (siblings) siblings.push(node);
|
||
else childrenOf.set(node.parentId, [node]);
|
||
}
|
||
type CopyEntry = { node: FileNode; children: CopyEntry[] };
|
||
const seen = new Set<string>();
|
||
const collect = (node: FileNode): CopyEntry => {
|
||
seen.add(node.id);
|
||
const below = isFolderNode(node) ? childrenOf.get(node.id) ?? [] : [];
|
||
return {
|
||
node: withDecodedName(node),
|
||
children: below
|
||
.filter(child => !seen.has(child.id) && fileNodeKind(child) !== 'symlink')
|
||
.map(collect),
|
||
};
|
||
};
|
||
const plan = collect(original);
|
||
|
||
// Stalwart 1.0 refuses nodes below maxFileNodeDepth; a copy that would
|
||
// reach past it fails here rather than part-way.
|
||
const maxDepth = this.getMaxFileNodeDepth(target.accountId);
|
||
if (maxDepth !== null) {
|
||
const height = (entry: CopyEntry): number =>
|
||
1 + entry.children.reduce((tallest, child) => Math.max(tallest, height(child)), 0);
|
||
const targetTree = target.accountId === source.accountId
|
||
? tree
|
||
: await this.fetchAllFileNodes(target.accountId).catch(() => null);
|
||
if (targetTree && fileNodeDepth(targetTree, target.id) + height(plan) > maxDepth) {
|
||
throw folderDepthError();
|
||
}
|
||
}
|
||
|
||
const createCopy = (node: FileNode, name: string, parent: string | null): Promise<FileNode> => {
|
||
const props: Record<string, unknown> = { name };
|
||
if (parent !== null) props.parentId = parent;
|
||
if (!isFolderNode(node)) {
|
||
props.type = this.fileNodeType(target.accountId, node.type);
|
||
props.blobId = node.blobId;
|
||
}
|
||
return this.createFileNodeIn(target.accountId, props, 'copy');
|
||
};
|
||
const copyChildren = async (entry: CopyEntry, parent: string): Promise<void> => {
|
||
for (const child of entry.children) {
|
||
const created = await createCopy(child.node, child.node.name, parent);
|
||
await copyChildren(child, created.id);
|
||
}
|
||
};
|
||
|
||
const root = await createCopy(plan.node, newName, target.id);
|
||
const copy = this.namespaceFileNode(root, target.accountId);
|
||
try {
|
||
await copyChildren(plan, root.id);
|
||
} catch (error) {
|
||
// Take the half-made copy away again (its children go with it) and
|
||
// report what stopped it.
|
||
await this.destroyFileNodes([copy.id]).catch((cleanup) => {
|
||
console.warn('[Files] Could not remove a partly created copy:', cleanup instanceof Error ? cleanup.message : cleanup);
|
||
});
|
||
throw error;
|
||
}
|
||
return copy;
|
||
}
|
||
|
||
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. All of
|
||
// them: Stalwart 1.0 also takes the copy's `$hasattachment` /
|
||
// `$hasnoattachment`, and with them its hasAttachment, only from here.
|
||
const srcResp = await this.request([
|
||
["Email/get", { accountId: fromAccountId, ids: [emailId], properties: ["keywords"] }, "0"],
|
||
]);
|
||
const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {};
|
||
|
||
// No onSuccessDestroyOriginal: Stalwart before 0.16.15 destroys the wrong
|
||
// id with it (support.stalw.art #1150), and from 0.16.15 it destroys the
|
||
// source even when the copy failed (over quota, not found), so a failed
|
||
// move lost the message. Destroy the source only once the copy exists.
|
||
const response = await this.request([
|
||
["Email/copy", {
|
||
fromAccountId,
|
||
accountId: toAccountId,
|
||
create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } },
|
||
}, "0"],
|
||
]);
|
||
const [name, res] = response.methodResponses?.[0] ?? [];
|
||
if (name === "error") {
|
||
throw new Error(res?.description || res?.type || "Failed to copy email across accounts");
|
||
}
|
||
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");
|
||
}
|
||
|
||
const destroyResponse = await this.request([
|
||
["Email/set", { accountId: fromAccountId, destroy: [emailId] }, "0"],
|
||
]);
|
||
this.assertEmailSetSucceeded(destroyResponse, "remove the moved email from its old account");
|
||
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 submissionAccountId = this.getSubmissionAccountId();
|
||
const submit = () => this.request([
|
||
['EmailSubmission/set', {
|
||
accountId: submissionAccountId,
|
||
create: { 'smime-submit': { emailId, identityId } },
|
||
}, '0'],
|
||
]);
|
||
let response = await submit();
|
||
if (submissionCreateError(response, 'smime-submit')?.type === 'overQuota') {
|
||
await this.makeRoomForSubmission(submissionAccountId);
|
||
response = await submit();
|
||
}
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notCreated?.['smime-submit']) {
|
||
const err = result.notCreated['smime-submit'];
|
||
if (err.type === 'overQuota') throw this.submissionQuotaError(err.description, submissionAccountId);
|
||
throw new Error(err.description || err.type || 'Failed to submit email');
|
||
}
|
||
await this.destroyImmediateSubmission(result?.created?.['smime-submit']?.id, submissionAccountId);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
// 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();
|
||
|
||
const { emailId, submissionId: emailSubmissionId, sendAt } = await this.importAndSubmitRaw(
|
||
['Email/import', {
|
||
accountId: this.accountId,
|
||
emails: {
|
||
'raw-import': {
|
||
blobId,
|
||
mailboxIds: { [importMailboxId]: true },
|
||
keywords: draftMailboxId ? { '$seen': true, '$draft': true } : { '$seen': true },
|
||
},
|
||
},
|
||
}, '0'],
|
||
'raw-import',
|
||
submissionAccountId,
|
||
(submitEmailId) => ({
|
||
accountId: submissionAccountId,
|
||
create: {
|
||
'raw-submit': {
|
||
emailId: submitEmailId,
|
||
identityId,
|
||
...(envelope ? { envelope } : {}),
|
||
},
|
||
},
|
||
...(draftMailboxId ? {
|
||
onSuccessUpdateEmail: {
|
||
'#raw-submit': {
|
||
...mailboxIdsReplacement(sentMailboxId),
|
||
'keywords/$draft': null,
|
||
},
|
||
},
|
||
} : {}),
|
||
}),
|
||
'Failed to send raw email',
|
||
);
|
||
let serverSendAt = sendAt;
|
||
|
||
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||
}
|
||
await this.destroyImmediateSubmission(emailSubmissionId, submissionAccountId, delayedUntil);
|
||
|
||
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);
|
||
const submissionAccountId = this.getSubmissionAccountId();
|
||
|
||
//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 { submissionId: emailSubmissionId, sendAt } = await this.importAndSubmitRaw(
|
||
['Email/import', {
|
||
accountId: this.accountId,
|
||
emails: {
|
||
'temp-submit': {
|
||
blobId,
|
||
mailboxIds: { [draftsMailbox.id]: true },
|
||
keywords: { '$draft': true },
|
||
},
|
||
},
|
||
}, '0'],
|
||
'temp-submit',
|
||
submissionAccountId,
|
||
(submitEmailId) => ({
|
||
accountId: submissionAccountId,
|
||
create: {
|
||
'raw-submit': {
|
||
emailId: submitEmailId,
|
||
identityId,
|
||
...(envelope ? { envelope } : {}),
|
||
},
|
||
},
|
||
//destroy the temporary email after submission to avoid leaving a draft behind.
|
||
onSuccessDestroyEmail: ['#raw-submit'],
|
||
}),
|
||
'Failed to submit raw email',
|
||
);
|
||
let serverSendAt = sendAt;
|
||
|
||
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||
}
|
||
await this.destroyImmediateSubmission(emailSubmissionId, submissionAccountId, delayedUntil);
|
||
|
||
return delayedUntil
|
||
? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true }
|
||
: { scheduled: false, emailSubmissionId, submissionAccountId, isSmime: true };
|
||
}
|
||
|
||
/**
|
||
* The Email/import + EmailSubmission/set request of the raw send paths.
|
||
* An overQuota refusal is tried once more after making room - the import
|
||
* exists by then, so it is submitted by id. Any other refusal takes the
|
||
* import back out: nothing was sent, and it would otherwise stay in Drafts
|
||
* (or in Sent, where it passes for a sent message).
|
||
*/
|
||
private async importAndSubmitRaw(
|
||
importCall: JMAPMethodCall,
|
||
importCreationId: string,
|
||
submissionAccountId: string,
|
||
submissionSet: (emailId: string) => Record<string, unknown>,
|
||
failureMessage: string,
|
||
): Promise<{ emailId?: string; submissionId?: string; sendAt?: string }> {
|
||
let response = await this.request([
|
||
importCall,
|
||
['EmailSubmission/set', submissionSet(`#${importCreationId}`), '1'],
|
||
]);
|
||
const importedId = response.methodResponses
|
||
?.find(([name]) => name === 'Email/import')?.[1]?.created?.[importCreationId]?.id as string | undefined;
|
||
if (importedId && submissionCreateError(response, 'raw-submit')?.type === 'overQuota') {
|
||
await this.makeRoomForSubmission(submissionAccountId);
|
||
const retry = await this.request([['EmailSubmission/set', submissionSet(importedId), '1']]);
|
||
response = {
|
||
...retry,
|
||
methodResponses: [
|
||
...response.methodResponses.filter(([name]) => name === 'Email/import'),
|
||
...(retry.methodResponses ?? []),
|
||
],
|
||
};
|
||
}
|
||
|
||
const { failure } = sendMethodErrors(response.methodResponses);
|
||
if (failure) {
|
||
await this.discardUnsentCopy(importedId, this.accountId);
|
||
throw jmapError(failure.description || `Failed: ${failure.type}`, failure);
|
||
}
|
||
let submissionId: string | undefined;
|
||
let sendAt: string | undefined;
|
||
for (const [methodName, result] of response.methodResponses ?? []) {
|
||
const r = result as { notCreated?: Record<string, JMAPSetError> };
|
||
if (r.notCreated) {
|
||
const firstErr = Object.values(r.notCreated)[0];
|
||
await this.discardUnsentCopy(importedId, this.accountId);
|
||
this.throwIfHoldTooLong(firstErr);
|
||
if (methodName === 'EmailSubmission/set' && firstErr?.type === 'overQuota') {
|
||
throw this.submissionQuotaError(firstErr.description, submissionAccountId);
|
||
}
|
||
throw jmapError(firstErr?.description || firstErr?.type || failureMessage, firstErr);
|
||
}
|
||
if (methodName === 'EmailSubmission/set') {
|
||
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
|
||
submissionId = created?.id;
|
||
sendAt = created?.sendAt;
|
||
}
|
||
}
|
||
return { emailId: importedId, submissionId, sendAt };
|
||
}
|
||
|
||
/**
|
||
* Destroy the record of a submission sent without a hold, as sendEmail
|
||
* does (see there): gated on Stalwart and on the request, never on the
|
||
* undoStatus the create returned.
|
||
*/
|
||
private async destroyImmediateSubmission(submissionId: string | undefined, accountId: string, delayedUntil?: string): Promise<void> {
|
||
if (delayedUntil || !submissionId || !this.hasAccountCapability('urn:stalwart:jmap', accountId)) return;
|
||
await this.destroyEmailSubmissions([submissionId], accountId);
|
||
}
|
||
|
||
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 {
|
||
// `total` only comes back with calculateTotal. Without it the loop
|
||
// stopped after the first page, and since the query lists records
|
||
// oldest sendAt first, mail scheduled furthest ahead dropped off the
|
||
// list once the three-day window held more than a page of records.
|
||
const queryResponse = await this.request([
|
||
['EmailSubmission/query', {
|
||
accountId: submissionAccountId,
|
||
limit: pageSize,
|
||
position: rawPosition,
|
||
calculateTotal: true,
|
||
}, '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 [name, result] = response.methodResponses?.[0] ?? [];
|
||
if (name === 'error') {
|
||
throw jmapError(result?.description || result?.type || 'Failed to cancel scheduled send', result);
|
||
}
|
||
const error = result?.notUpdated?.[submissionId];
|
||
if (error) {
|
||
throw jmapError(error.description || error.type || 'Failed to cancel scheduled send', error);
|
||
}
|
||
// The canceled record is dead weight against the submission limit.
|
||
// Destroyed in a request of its own, and only once the cancel is
|
||
// confirmed: had the two gone together and the cancel failed, the mail
|
||
// would still be queued with no record left to cancel it through.
|
||
if (result?.updated && submissionId in result.updated) {
|
||
await this.destroyEmailSubmissions([submissionId], submissionAccountId);
|
||
}
|
||
}
|
||
|
||
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 createReplacement = (holdFor: number) => {
|
||
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdFor, envelopeRecipients);
|
||
return this.request([
|
||
['EmailSubmission/set', {
|
||
accountId: submissionAccountId,
|
||
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
|
||
...(draftsMailbox && sentMailbox ? {
|
||
onSuccessUpdateEmail: {
|
||
'#replacement': {
|
||
...mailboxIdsReplacement(sentMailbox.id),
|
||
'keywords/$draft': null,
|
||
},
|
||
},
|
||
} : {}),
|
||
}, '0'],
|
||
]);
|
||
};
|
||
let response = await createReplacement(holdForSeconds);
|
||
// The original is still pending, so a prune can't touch it.
|
||
if (submissionCreateError(response, 'replacement')?.type === 'overQuota') {
|
||
await this.makeRoomForSubmission(submissionAccountId);
|
||
// Making room can take a while - a prune, a walk through the change
|
||
// log - and meanwhile the original may have gone out: its sendAt came,
|
||
// or it was "Send now" of a message due any moment. A replacement then
|
||
// would send it a second time.
|
||
const original = await this.getSubmissionStatus(submissionId, submissionAccountId);
|
||
if (original !== 'pending') throw new NoLongerScheduledError(original === 'final');
|
||
// HOLDFOR counts from when the server takes the request, so take the
|
||
// time spent off it; a moment already past goes out at once.
|
||
response = await createReplacement(Math.max(1, Math.ceil((Date.parse(delayedUntil) - Date.now()) / 1000)));
|
||
}
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
const createError = result?.notCreated?.replacement;
|
||
if (createError) {
|
||
if (createError.type === 'overQuota') throw this.submissionQuotaError(createError.description, submissionAccountId);
|
||
throw jmapError(createError.description || createError.type || 'Failed to reschedule email', createError);
|
||
}
|
||
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);
|
||
// Either cancel also destroys the record it canceled, so a reschedule
|
||
// leaves only the replacement behind.
|
||
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');
|
||
}
|
||
}
|
||
|
||
// ── EmailSubmission records ───────────────────────────────────────
|
||
// Every send leaves an EmailSubmission record behind. Stalwart 1.0 counts
|
||
// all of them, whatever their status or age, against a per-account limit
|
||
// (`maxSubmissions`, 500 by default) and its own purge of old records never
|
||
// runs, so an account that never removes them stops sending for good.
|
||
// Destroying a record only removes the record - on 0.16 and 1.0 it neither
|
||
// cancels nor stops the delivery - but a still-pending submission whose
|
||
// record is gone can no longer be cancelled. So only records that can't be
|
||
// cancelled any more are ever destroyed.
|
||
|
||
/**
|
||
* Destroy EmailSubmission records, in batches the server accepts. Callers
|
||
* must only pass records that can no longer be cancelled.
|
||
*
|
||
* Resolves with the ids the server destroyed. Ids it no longer has count as
|
||
* done; other failures are logged. Never rejects, so no send fails over it.
|
||
*/
|
||
async destroyEmailSubmissions(ids: string[], accountId?: string): Promise<string[]> {
|
||
const submissionAccountId = accountId ?? this.getSubmissionAccountId();
|
||
const destroyed: string[] = [];
|
||
for (const batch of batched([...new Set(ids)], this.getMaxObjectsInSet())) {
|
||
try {
|
||
const response = await this.request([submissionDestroyCall(batch, submissionAccountId, '0')]);
|
||
destroyed.push(...readSubmissionDestroy(response, '0'));
|
||
} catch (error) {
|
||
console.warn('[EmailSubmission] destroy failed:', error);
|
||
}
|
||
}
|
||
return destroyed;
|
||
}
|
||
|
||
/**
|
||
* Destroy, in every submission account, the records that can no longer be
|
||
* cancelled: canceled ones, and final ones whose sendAt has passed (see
|
||
* prunableSubmissionIds). That includes other clients' and other group
|
||
* members' records - the limit is per account. EmailSubmission/query only
|
||
* sees records whose sendAt is at most three days back, so older ones are
|
||
* left to makeRoomForSubmission.
|
||
*
|
||
* Runs at most once per account every ten minutes unless `force` is set.
|
||
* Resolves with the number of records destroyed; never rejects.
|
||
*/
|
||
async pruneEmailSubmissions(options: { force?: boolean } = {}): Promise<number> {
|
||
let destroyed = 0;
|
||
for (const accountId of this.getSubmissionAccountIds()) {
|
||
destroyed += await this.pruneSubmissionAccount(accountId, options.force ?? false);
|
||
}
|
||
return destroyed;
|
||
}
|
||
|
||
private async pruneSubmissionAccount(accountId: string, force: boolean): Promise<number> {
|
||
const now = Date.now();
|
||
const lastRun = this.submissionPrunedAt.get(accountId);
|
||
if (!force && lastRun !== undefined && now - lastRun < SUBMISSION_PRUNE_INTERVAL_MS) return 0;
|
||
this.submissionPrunedAt.set(accountId, now);
|
||
try {
|
||
const prunable = await this.findPrunableSubmissions(accountId, now);
|
||
if (prunable.length === 0) return 0;
|
||
return (await this.destroyEmailSubmissions(prunable, accountId)).length;
|
||
} catch (error) {
|
||
console.warn(`[EmailSubmission] pruning ${accountId} failed:`, error);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Page through the records the query can see that might go - past sendAt
|
||
* or canceled, a superset either way - and let EmailSubmission/get decide.
|
||
*/
|
||
private async findPrunableSubmissions(accountId: string, now: number): Promise<string[]> {
|
||
const pageSize = Math.min(500, this.getMaxObjectsInGet());
|
||
const before = new Date(now).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||
const prunable: string[] = [];
|
||
let position = 0;
|
||
for (let page = 0; page < SUBMISSION_SCAN_MAX_PAGES; page++) {
|
||
const response = await this.request([
|
||
['EmailSubmission/query', {
|
||
accountId,
|
||
filter: { operator: 'OR', conditions: [{ before }, { undoStatus: 'canceled' }] },
|
||
position,
|
||
limit: pageSize,
|
||
calculateTotal: true,
|
||
}, '0'],
|
||
['EmailSubmission/get', {
|
||
accountId,
|
||
'#ids': { resultOf: '0', name: 'EmailSubmission/query', path: '/ids' },
|
||
properties: ['id', 'sendAt', 'undoStatus'],
|
||
}, '1'],
|
||
]);
|
||
assertQuerySucceeded(response, 'EmailSubmission/query');
|
||
const query = response.methodResponses[0][1] as { ids?: string[]; total?: number };
|
||
const ids = query.ids ?? [];
|
||
prunable.push(...prunableSubmissionIds(this.submissionGetList(response, '1'), now));
|
||
position += ids.length;
|
||
const done = typeof query.total === 'number' ? position >= query.total : ids.length < pageSize;
|
||
if (ids.length === 0 || done) break;
|
||
}
|
||
return prunable;
|
||
}
|
||
|
||
/** The list of an EmailSubmission/get response; throws when the get failed. */
|
||
private submissionGetList(response: JMAPResponse, callId: string): Array<Pick<EmailSubmission, 'id' | 'sendAt' | 'undoStatus'>> {
|
||
const [name, result] = response.methodResponses?.find(([, , id]) => id === callId) ?? [];
|
||
if (name !== 'EmailSubmission/get') {
|
||
throw jmapError(`EmailSubmission/get failed: ${result?.description || result?.type || 'unknown error'}`, result);
|
||
}
|
||
return (result?.list ?? []) as Array<Pick<EmailSubmission, 'id' | 'sendAt' | 'undoStatus'>>;
|
||
}
|
||
|
||
/**
|
||
* The undoStatus EmailSubmission/get reports for a submission now, which
|
||
* Stalwart works out from its queue; undefined once the record is gone.
|
||
* Throws when the get fails.
|
||
*/
|
||
private async getSubmissionStatus(submissionId: string, accountId: string): Promise<EmailSubmission['undoStatus'] | undefined> {
|
||
const response = await this.request([
|
||
['EmailSubmission/get', { accountId, ids: [submissionId], properties: ['id', 'undoStatus'] }, '0'],
|
||
]);
|
||
return this.submissionGetList(response, '0').find((submission) => submission.id === submissionId)?.undoStatus;
|
||
}
|
||
|
||
/**
|
||
* Make room after an EmailSubmission/set create came back overQuota. Prunes
|
||
* the account at once; when that frees nothing, the records taking up the
|
||
* room are older than the query can see (sendAt more than three days back).
|
||
* On Stalwart their ids are still in the change log (the last 10,000
|
||
* changes by default), and get and destroy by id work at any age, so they
|
||
* are found there and pruned by the same rules. Never rejects: the caller
|
||
* retries the create either way, since another tab may have made room in
|
||
* the meantime.
|
||
*
|
||
* At a full quota every attempt ends up here - each send, reschedule and
|
||
* automatic read receipt. When a search found nothing that can go, the
|
||
* account is not searched again for a few minutes: what is left is pending
|
||
* or out of reach, and looking again right away would cost the prune, the
|
||
* change-log walk and the gets once more for the same answer.
|
||
*/
|
||
private async makeRoomForSubmission(accountId: string): Promise<void> {
|
||
const recheckAt = this.submissionRoomRecheckAt.get(accountId);
|
||
if (recheckAt !== undefined && Date.now() < recheckAt) return;
|
||
const now = Date.now();
|
||
this.submissionPrunedAt.set(accountId, now);
|
||
let found = 0;
|
||
let freed = 0;
|
||
let complete = true;
|
||
const prune = async (lookUp: () => Promise<string[]>, what: string) => {
|
||
try {
|
||
const prunable = await lookUp();
|
||
found += prunable.length;
|
||
if (prunable.length > 0) freed += (await this.destroyEmailSubmissions(prunable, accountId)).length;
|
||
} catch (error) {
|
||
complete = false;
|
||
console.warn(`[EmailSubmission] looking up ${what} of ${accountId} failed:`, error);
|
||
}
|
||
};
|
||
await prune(() => this.findPrunableSubmissions(accountId, now), 'recent submissions');
|
||
if (freed === 0 && this.hasAccountCapability('urn:stalwart:jmap', accountId)) {
|
||
await prune(() => this.findOlderPrunableSubmissions(accountId), 'older submissions');
|
||
}
|
||
// A failed lookup proves nothing, so only an answer of "nothing" counts.
|
||
if (complete && found === 0) this.submissionRoomRecheckAt.set(accountId, Date.now() + SUBMISSION_ROOM_RECHECK_MS);
|
||
else this.submissionRoomRecheckAt.delete(accountId);
|
||
}
|
||
|
||
/** The records in the account's change log that can go (see submissionIdsFromChangeLog). */
|
||
private async findOlderPrunableSubmissions(accountId: string): Promise<string[]> {
|
||
const ids = await this.submissionIdsFromChangeLog(accountId);
|
||
const now = Date.now();
|
||
const prunable: string[] = [];
|
||
for (const batch of batched(ids, Math.min(500, this.getMaxObjectsInGet()))) {
|
||
const response = await this.request([
|
||
['EmailSubmission/get', { accountId, ids: batch, properties: ['id', 'sendAt', 'undoStatus'] }, '0'],
|
||
]);
|
||
prunable.push(...prunableSubmissionIds(this.submissionGetList(response, '0'), now));
|
||
}
|
||
return prunable;
|
||
}
|
||
|
||
/**
|
||
* Every submission id still in the account's change log. Stalwart reads
|
||
* sinceState "n" as its initial state and answers with the whole log, with
|
||
* a record created and destroyed within it left out.
|
||
*/
|
||
private async submissionIdsFromChangeLog(accountId: string): Promise<string[]> {
|
||
const ids = new Set<string>();
|
||
let sinceState = 'n';
|
||
for (let page = 0; page < SUBMISSION_SCAN_MAX_PAGES; page++) {
|
||
const response = await this.request([
|
||
['EmailSubmission/changes', { accountId, sinceState }, '0'],
|
||
]);
|
||
const [name, result] = response.methodResponses?.[0] ?? [];
|
||
if (name !== 'EmailSubmission/changes') {
|
||
throw jmapError(`EmailSubmission/changes failed: ${result?.description || result?.type || 'unknown error'}`, result);
|
||
}
|
||
for (const id of [...(result.created ?? []), ...(result.updated ?? [])] as string[]) ids.add(id);
|
||
for (const id of (result.destroyed ?? []) as string[]) ids.delete(id);
|
||
if (!result.hasMoreChanges || !result.newState || result.newState === sinceState) break;
|
||
sinceState = result.newState;
|
||
}
|
||
return [...ids];
|
||
}
|
||
|
||
/**
|
||
* Destroy the message a send created for a submission that then failed:
|
||
* sendEmail's Drafts copy, an imported read receipt or raw message. Nothing
|
||
* was sent, and leaving it would make it look sent (in Sent) or pile up one
|
||
* duplicate per attempt (in Drafts). Best effort: the send error is what
|
||
* the caller reports.
|
||
*/
|
||
private async discardUnsentCopy(emailId: string | undefined, accountId: string): Promise<void> {
|
||
if (!emailId) return;
|
||
try {
|
||
await this.deleteEmail(emailId, accountId);
|
||
} catch (error) {
|
||
console.warn('[EmailSubmission] could not remove the unsent message:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The error for a submission create the account refused with overQuota,
|
||
* noting whether the account is on Stalwart: its limit is the count of
|
||
* submission records, which only an administrator can raise once the
|
||
* prune freed nothing.
|
||
*/
|
||
private submissionQuotaError(description: string | undefined, accountId: string): SubmissionQuotaError {
|
||
return new SubmissionQuotaError(description, this.hasAccountCapability('urn:stalwart:jmap', accountId));
|
||
}
|
||
|
||
// ── 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'];
|
||
}
|
||
|
||
// The body of the one PushSubscription call in a response. A method-level
|
||
// error throws a plain jmapError: the server refused the whole call, which
|
||
// says nothing about any one subscription.
|
||
private pushMethodResult<T>(response: JMAPResponse, method: string): T {
|
||
const [name, body] = response.methodResponses?.[0] ?? [];
|
||
if (name !== method) {
|
||
const error = body as JmapErrorInfo | undefined;
|
||
throw jmapError(`${method} failed: ${error?.description || error?.type || 'no response'}`, error);
|
||
}
|
||
return (body ?? {}) as T;
|
||
}
|
||
|
||
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),
|
||
);
|
||
// Throws rather than answering []: to web-push an empty list means "the
|
||
// server dropped our subscription", which forgets a working registration.
|
||
return this.pushMethodResult<{ list?: PushSubscription[] }>(response, 'PushSubscription/get').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 result = this.pushMethodResult<{
|
||
created?: { new?: { id?: string } };
|
||
notCreated?: { new?: JmapErrorInfo };
|
||
}>(response, 'PushSubscription/set');
|
||
// Stalwart refuses the whole emailPush map as `forbidden` when it names an
|
||
// account the user doesn't own, and answers `overQuota` past its
|
||
// subscription limit (15 per account by default).
|
||
const refused = result.notCreated?.new;
|
||
if (refused) {
|
||
throw jmapSetError(
|
||
`PushSubscription/set create failed: ${refused.description || refused.type || 'unknown error'}`,
|
||
refused,
|
||
);
|
||
}
|
||
const id = result.created?.new?.id;
|
||
if (!id) throw new Error('PushSubscription/set create returned no id');
|
||
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 result = this.pushMethodResult<{ notUpdated?: Record<string, JmapErrorInfo> }>(
|
||
response,
|
||
'PushSubscription/set',
|
||
);
|
||
const refused = result.notUpdated?.[id];
|
||
if (refused) {
|
||
throw jmapSetError(
|
||
`PushSubscription verification failed: ${refused.description || refused.type || 'unknown error'}`,
|
||
refused,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Throws when the server refuses the update: a jmapSetError with the
|
||
// SetError type (`notFound` once the subscription is gone, `forbidden` for
|
||
// an emailPush map naming an account the user may not subscribe to), a
|
||
// plain error for anything that says nothing about the subscription.
|
||
async updatePushSubscription(
|
||
id: string,
|
||
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> | null },
|
||
): Promise<void> {
|
||
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 result = this.pushMethodResult<{
|
||
updated?: Record<string, unknown> | null;
|
||
notUpdated?: Record<string, JmapErrorInfo>;
|
||
}>(response, 'PushSubscription/set');
|
||
const refused = result.notUpdated?.[id];
|
||
if (refused) {
|
||
throw jmapSetError(
|
||
`PushSubscription/set update failed: ${refused.description || refused.type || 'unknown error'}`,
|
||
refused,
|
||
);
|
||
}
|
||
if (!result.updated || !(id in result.updated)) {
|
||
throw new Error(`PushSubscription/set did not report ${id} as updated`);
|
||
}
|
||
}
|
||
|
||
async destroyPushSubscription(id: string): Promise<void> {
|
||
await this.request(
|
||
[['PushSubscription/set', { destroy: [id] }, '0']],
|
||
['urn:ietf:params:jmap:core'],
|
||
);
|
||
}
|
||
}
|