mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
8608 lines
331 KiB
TypeScript
8608 lines
331 KiB
TypeScript
import { generateUUID } from '@/lib/utils';
|
||
import type { Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||
import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface";
|
||
import { toWildcardQuery } from "./search-utils";
|
||
import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet";
|
||
import { batched, itemsPerRequest } from "./request-limits";
|
||
import { keywordPointer } from "./patch-pointer";
|
||
import { FirstTouchGate } from "./first-touch-gate";
|
||
import { debug } from "@/lib/debug";
|
||
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
|
||
import { SYNTHETIC_ID_PROBE, RECURRENCE_BASE_PROPERTIES, hydrateRecurrenceInstances, isServerRecurrenceInstance } from "@/lib/recurrence-instances";
|
||
import { findTasksOnlyCalendarIds, isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-component-detection";
|
||
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
|
||
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
|
||
import { decodeFileNodeName } from "./filenode-name";
|
||
import { getEffectiveTimeZone } from "@/lib/timezone";
|
||
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
|
||
|
||
// Names of nodes created over WebDAV come back percent-encoded from
|
||
// FileNode/get (see decodeFileNodeName / #869). Normalize at the boundary so
|
||
// every consumer (browser, sidebar, breadcrumbs, path resolution) sees the
|
||
// human-readable name.
|
||
function withDecodedName<T extends Pick<FileNode, 'name'>>(node: T): T {
|
||
const name = decodeFileNodeName(node.name);
|
||
return name === node.name ? node : { ...node, name };
|
||
}
|
||
|
||
/**
|
||
* Parse a recipient string that may be "Name <email>" or bare "email" into
|
||
* { name?, email }. The display name is unquoted and stripped of any address
|
||
* it carries inline: JMAP takes the name and the address as separate fields,
|
||
* so leaving the quoting or a second copy of the address in there makes the
|
||
* server emit an invalid To/Cc mailbox, which it then re-parses into a
|
||
* malformed envelope recipient (#672).
|
||
*/
|
||
function parseRecipientString(s: string): { name?: string; email: string } {
|
||
return splitMailbox(s);
|
||
}
|
||
|
||
/**
|
||
* Build the `mailboxIds` portion of an `Email/set` PatchObject as a full-property
|
||
* replacement — `{ mailboxIds: { <id>: true, ... } }` — instead of per-id
|
||
* `mailboxIds/<id>` JSON-Pointer patches.
|
||
*
|
||
* Two reasons:
|
||
* 1. It states the actual intent of a post-send / undo-send move: the message
|
||
* should belong to *exactly* the given mailbox(es).
|
||
* 2. It avoids per-id JSON-Pointer tokens entirely. Stalwart (observed on
|
||
* 0.15.5) rejects an `Email/set` PatchObject whose pointer token is a
|
||
* purely-numeric string — e.g. `mailboxIds/0` for a mailbox whose JMAP id is
|
||
* "0" — with `invalidProperties: "Invalid patch value"` (it treats the digits
|
||
* as a JSON-Pointer array index even though `mailboxIds` is a JSON object;
|
||
* cf. RFC 6901 §4, and RFC 8620 §1.2's warning against interop-hostile ids).
|
||
* That silently stranded already-delivered mail in Drafts for accounts whose
|
||
* Drafts/Sent mailbox id happened to be all digits (a full member of `0`,
|
||
* `1`, … `9`, `10`, … was verified rejected; ids containing a letter work).
|
||
* Stalwart fixed the parsing in 0.16.5 (stalwartlabs/stalwart@175f34ea,
|
||
* jmap-tools 0.1.5), but earlier deployments remain in the wild — and not
|
||
* emitting interop-hostile pointer tokens is the safer shape regardless.
|
||
*
|
||
* This is a *replacement*: it drops any other mailbox membership the message
|
||
* had, so callers must know the complete target set. Do NOT also place a
|
||
* `mailboxIds/<id>` pointer key in the same PatchObject — a pointer whose prefix
|
||
* is another key in the object is illegal (RFC 8620 §5.3).
|
||
*/
|
||
function mailboxIdsReplacement(
|
||
mailboxId: string,
|
||
...moreMailboxIds: string[]
|
||
): { mailboxIds: Record<string, true> } {
|
||
const mailboxIds: Record<string, true> = { [mailboxId]: true };
|
||
for (const id of moreMailboxIds) mailboxIds[id] = true;
|
||
return { mailboxIds };
|
||
}
|
||
|
||
export class RateLimitError extends Error {
|
||
retryAfterMs: number;
|
||
constructor(retryAfterMs: number) {
|
||
super('Rate limited by server');
|
||
this.name = 'RateLimitError';
|
||
this.retryAfterMs = retryAfterMs;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A request produced no response headers within its deadline.
|
||
*
|
||
* `fetch()` has no timeout of its own, and a half-dead connection - the OS or a
|
||
* NAT dropped the socket while the browser still believes it is usable - never
|
||
* rejects. iOS hits this constantly: it freezes a backgrounded tab (and, more
|
||
* aggressively, a home-screen web app), and on resume WebKit reuses pooled
|
||
* connections the network has already torn down. The request bytes leave, the
|
||
* server may even act on them, but the response never comes back. Without a
|
||
* deadline the caller's promise stays pending forever, so the composer's Send
|
||
* button stays disabled and "Save draft" never closes the composer (#702).
|
||
*/
|
||
export class RequestTimeoutError extends Error {
|
||
constructor(timeoutMs: number) {
|
||
super(`Request timed out after ${Math.round(timeoutMs / 1000)}s`);
|
||
this.name = 'RequestTimeoutError';
|
||
}
|
||
}
|
||
|
||
// 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]>;
|
||
}
|
||
|
||
const DEFAULT_MAILBOX_RIGHTS = {
|
||
mayReadItems: true,
|
||
mayAddItems: true,
|
||
mayRemoveItems: true,
|
||
maySetSeen: true,
|
||
maySetKeywords: true,
|
||
mayCreateChild: true,
|
||
mayRename: true,
|
||
mayDelete: true,
|
||
maySubmit: true,
|
||
} as const;
|
||
|
||
const EMAIL_LIST_PROPERTIES = [
|
||
"id",
|
||
"threadId",
|
||
"mailboxIds",
|
||
"keywords",
|
||
"size",
|
||
"receivedAt",
|
||
"from",
|
||
"to",
|
||
"cc",
|
||
"subject",
|
||
"preview",
|
||
"hasAttachment",
|
||
// Needed so list rows can serve drag-out to the file system as .eml.
|
||
"blobId",
|
||
] as const;
|
||
|
||
/**
|
||
* How many messages `discoverKeywords` walks before it gives up and reports an
|
||
* incomplete scan. A keyword only has to sit on one message to be worth
|
||
* recovering, so there is no cheaper place to stop than "all of them" - the cap
|
||
* exists so a very large account cannot turn a settings page into an unbounded
|
||
* page-by-page crawl, not because the tail is uninteresting.
|
||
*/
|
||
export const DEFAULT_KEYWORD_SCAN_LIMIT = 25000;
|
||
|
||
// How long an inconclusive keyword-sort polarity probe (#718) is trusted
|
||
// before the next folder fetch tries again.
|
||
const KEYWORD_SORT_PROBE_RETRY_MS = 5 * 60 * 1000;
|
||
|
||
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
|
||
// includeInAvailability, and the default-alerts properties. Without an explicit
|
||
// `properties` list the share indicator and share dialog can't see existing
|
||
// shares after a fresh login (only the optimistic in-memory update from the
|
||
// share action would carry it). Always request the full set we render.
|
||
const CALENDAR_PROPERTIES = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"color",
|
||
"sortOrder",
|
||
"isSubscribed",
|
||
"isVisible",
|
||
"isDefault",
|
||
"includeInAvailability",
|
||
"defaultAlertsWithTime",
|
||
"defaultAlertsWithoutTime",
|
||
"timeZone",
|
||
"shareWith",
|
||
"myRights",
|
||
] as const;
|
||
|
||
// Properties Stalwart's Calendar/set accepts in create/update. Anything else
|
||
// (id, isDefault, myRights, client-side bookkeeping fields) makes the whole
|
||
// update fail with invalidProperties ("Field could not be set").
|
||
const CALENDAR_SETTABLE_PROPERTIES = new Set([
|
||
"name",
|
||
"description",
|
||
"color",
|
||
"timeZone",
|
||
"sortOrder",
|
||
"isSubscribed",
|
||
"isVisible",
|
||
"includeInAvailability",
|
||
"defaultAlertsWithTime",
|
||
"defaultAlertsWithoutTime",
|
||
"shareWith",
|
||
]);
|
||
|
||
// Stalwart's default property list for AddressBook/get omits shareWith, so
|
||
// existing shares would be invisible after a fresh login.
|
||
const ADDRESS_BOOK_PROPERTIES = [
|
||
"id",
|
||
"name",
|
||
"description",
|
||
"sortOrder",
|
||
"isDefault",
|
||
"isSubscribed",
|
||
"shareWith",
|
||
"myRights",
|
||
] as const;
|
||
|
||
/**
|
||
* Detect whether a calendar object returned by the server is actually a
|
||
* task (VTODO) rather than an event (VEVENT). CalDAV clients like
|
||
* Thunderbird create VTODOs that Stalwart exposes through the
|
||
* CalendarEvent endpoints without a reliable `@type` discriminator.
|
||
*/
|
||
function isTaskObject(obj: { '@type'?: string; progress?: unknown; due?: unknown; percentComplete?: unknown }): boolean {
|
||
const type = obj['@type'];
|
||
if (typeof type === 'string' && type.toLowerCase() === 'task') return true;
|
||
// CalDAV-created tasks may lack @type='Task' - detect by task-specific fields
|
||
if (type !== 'Event' && (
|
||
('progress' in obj && typeof obj.progress === 'string') ||
|
||
('due' in obj && obj.due != null) ||
|
||
('percentComplete' in obj)
|
||
)) return true;
|
||
return false;
|
||
}
|
||
|
||
const CALENDAR_EVENT_PROPERTIES = [
|
||
'id',
|
||
'@type',
|
||
'uid',
|
||
'calendarIds',
|
||
'title',
|
||
'description',
|
||
'descriptionContentType',
|
||
'created',
|
||
'updated',
|
||
'sequence',
|
||
'start',
|
||
'duration',
|
||
'timeZone',
|
||
'showWithoutTime',
|
||
'utcStart',
|
||
'utcEnd',
|
||
'status',
|
||
'freeBusyStatus',
|
||
'privacy',
|
||
'color',
|
||
'keywords',
|
||
'categories',
|
||
'locale',
|
||
'replyTo',
|
||
'organizerCalendarAddress',
|
||
'participants',
|
||
'mayInviteSelf',
|
||
'mayInviteOthers',
|
||
'hideAttendees',
|
||
'recurrenceId',
|
||
'recurrenceIdTimeZone',
|
||
'recurrenceRule',
|
||
'recurrenceOverrides',
|
||
'excludedRecurrenceRule',
|
||
'useDefaultAlerts',
|
||
'alerts',
|
||
'locations',
|
||
'virtualLocations',
|
||
'links',
|
||
'relatedTo',
|
||
'isDraft',
|
||
'isOrigin',
|
||
'baseEventId',
|
||
] as const;
|
||
|
||
// Task-specific properties for CalendarEvent/get when fetching Task objects
|
||
const CALENDAR_TASK_PROPERTIES = [
|
||
'id',
|
||
'@type',
|
||
'uid',
|
||
'calendarIds',
|
||
'title',
|
||
'description',
|
||
'descriptionContentType',
|
||
'created',
|
||
'updated',
|
||
'start',
|
||
'due',
|
||
'duration',
|
||
'timeZone',
|
||
'showWithoutTime',
|
||
'utcStart',
|
||
'utcEnd',
|
||
'progress',
|
||
'progressUpdated',
|
||
'priority',
|
||
'privacy',
|
||
'color',
|
||
'keywords',
|
||
'categories',
|
||
'recurrenceRule',
|
||
'recurrenceOverrides',
|
||
'excludedRecurrenceRule',
|
||
'useDefaultAlerts',
|
||
'alerts',
|
||
'relatedTo',
|
||
'percentComplete', // Task-only per RFC 8984 §5.2.4 - used in detection heuristic
|
||
] as const;
|
||
|
||
/**
|
||
* IANA time zone of the user - their `timeZone` setting when set (#755),
|
||
* otherwise the browser's - sent as the `timeZone` argument on
|
||
* CalendarEvent/query and CalendarEvent/get. Stalwart interprets the
|
||
* LocalDateTime `after`/`before` filter values and computes utcStart/utcEnd
|
||
* for floating events in this zone, defaulting to UTC when absent - which
|
||
* shifts range boundaries and floating-event times for any user not in UTC.
|
||
* Stalwart ignores unparseable values, so sending it is always safe.
|
||
*/
|
||
function getUserTimeZone(): string | undefined {
|
||
try {
|
||
return getEffectiveTimeZone() || undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stalwart's calcard crate uses singular property names ("recurrenceRule")
|
||
* instead of the RFC 8984 plural forms ("recurrenceRules").
|
||
* JSCalendar 2.0 (jscalendarbis-15) defines recurrenceRule as a single object,
|
||
* not an array. This function converts our internal array form to a single
|
||
* object, cleans null values, and renames the properties.
|
||
*/
|
||
function cleanRecurrenceRules(event: Record<string, unknown>): void {
|
||
const keyMap: Record<string, string> = {
|
||
recurrenceRules: 'recurrenceRule',
|
||
excludedRecurrenceRules: 'excludedRecurrenceRule',
|
||
};
|
||
for (const [pluralKey, singularKey] of Object.entries(keyMap)) {
|
||
const rules = event[pluralKey];
|
||
if (rules === undefined) continue;
|
||
delete event[pluralKey];
|
||
if (!Array.isArray(rules)) {
|
||
// null means "remove recurrence" - pass through with the correct key
|
||
event[singularKey] = rules;
|
||
continue;
|
||
}
|
||
if (rules.length === 0) {
|
||
event[singularKey] = null;
|
||
continue;
|
||
}
|
||
// JSCalendar 2.0: recurrenceRule is a single object, use first rule
|
||
const rule = rules[0] as Record<string, unknown>;
|
||
const cleaned: Record<string, unknown> = {};
|
||
for (const [k, v] of Object.entries(rule)) {
|
||
if (v !== null) cleaned[k] = v;
|
||
}
|
||
event[singularKey] = cleaned;
|
||
}
|
||
}
|
||
|
||
function getCalendarEventDebugSnapshot(event: Partial<CalendarEvent> | null | undefined): Record<string, unknown> | null {
|
||
if (!event) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: event.id,
|
||
originalId: event.originalId,
|
||
uid: event.uid,
|
||
'@type': event['@type'],
|
||
title: event.title,
|
||
start: event.start,
|
||
duration: event.duration,
|
||
timeZone: event.timeZone,
|
||
showWithoutTime: event.showWithoutTime,
|
||
utcStart: event.utcStart,
|
||
utcEnd: event.utcEnd,
|
||
status: event.status,
|
||
freeBusyStatus: event.freeBusyStatus,
|
||
calendarIds: event.calendarIds,
|
||
originalCalendarIds: event.originalCalendarIds,
|
||
accountId: event.accountId,
|
||
accountName: event.accountName,
|
||
isShared: event.isShared,
|
||
recurrenceId: event.recurrenceId,
|
||
recurrenceRules: event.recurrenceRules,
|
||
sequence: event.sequence,
|
||
created: event.created,
|
||
updated: event.updated,
|
||
};
|
||
}
|
||
|
||
function namespaceMailboxIds(emails: Email[], accountId: string): void {
|
||
for (const email of emails) {
|
||
if (!email.mailboxIds) continue;
|
||
const namespaced: Record<string, boolean> = {};
|
||
for (const mbId of Object.keys(email.mailboxIds)) {
|
||
namespaced[`${accountId}:${mbId}`] = email.mailboxIds[mbId];
|
||
}
|
||
email.mailboxIds = namespaced;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* RFC 8620 §3.6.1 limit error naming the server's parallel-request ceiling.
|
||
* Stalwart refuses the surplus of a burst with 400 jmap:error:limit /
|
||
* maxConcurrentRequests BEFORE any method runs, so the request is safe to
|
||
* replay - even a /set.
|
||
*/
|
||
export function isConcurrentRequestRefusal(status: number, body: string): boolean {
|
||
if (status !== 400) return false;
|
||
try {
|
||
const parsed = JSON.parse(body) as { type?: unknown; limit?: unknown } | null;
|
||
return parsed?.type === 'urn:ietf:params:jmap:error:limit' && parsed?.limit === 'maxConcurrentRequests';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/** Base back-off per attempt when the server refuses a request for being one too many in parallel. */
|
||
const CONCURRENT_REQUEST_RETRY_DELAYS_MS = [200, 400, 800];
|
||
|
||
function computeHasMore(position: number, emailCount: number, total: number, limit: number): boolean {
|
||
if (total > 0) return (position + emailCount) < total;
|
||
return emailCount === limit;
|
||
}
|
||
|
||
function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean {
|
||
return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/'));
|
||
}
|
||
|
||
function isSmimeEmail(email: Email): boolean {
|
||
const types: string[] = [];
|
||
const collect = (part: Email['bodyStructure']): void => {
|
||
if (!part) return;
|
||
if (part.type) types.push(part.type.toLowerCase());
|
||
part.subParts?.forEach(collect);
|
||
};
|
||
collect(email.bodyStructure);
|
||
email.attachments?.forEach(att => types.push((att.type || '').toLowerCase()));
|
||
return types.some(type =>
|
||
type.includes('pkcs7') ||
|
||
type.includes('x-pkcs7') ||
|
||
type === 'application/pkcs7-mime' ||
|
||
type === 'application/pkcs7-signature'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Fold a single iCalendar content line per RFC 5545 §3.1.
|
||
* Lines longer than 75 octets MUST be split with CRLF + a single linear white space character.
|
||
* We fold at 74 characters to leave room for the leading space on continuation lines.
|
||
* @see https://www.rfc-editor.org/rfc/rfc5545#section-3.1
|
||
*/
|
||
function foldIcsLine(line: string): string {
|
||
const MAX = 74;
|
||
if (line.length <= MAX) return line;
|
||
const chunks: string[] = [];
|
||
chunks.push(line.slice(0, MAX));
|
||
let pos = MAX;
|
||
while (pos < line.length) {
|
||
chunks.push(' ' + line.slice(pos, pos + MAX - 1));
|
||
pos += MAX - 1;
|
||
}
|
||
return chunks.join('\r\n');
|
||
}
|
||
|
||
/**
|
||
* Escape a TEXT property value per RFC 5545 §3.3.11. Backslash, semicolon,
|
||
* comma and newlines must be escaped - otherwise a title like "1,2;3" or a
|
||
* multi-line description corrupts the component.
|
||
* @see https://www.rfc-editor.org/rfc/rfc5545#section-3.3.11
|
||
*/
|
||
function escapeIcsText(value: string): string {
|
||
return value
|
||
.replace(/\\/g, '\\\\')
|
||
.replace(/;/g, '\\;')
|
||
.replace(/,/g, '\\,')
|
||
.replace(/\r\n|\r|\n/g, '\\n');
|
||
}
|
||
|
||
/**
|
||
* Encode a parameter value (e.g. CN=) per RFC 5545 §3.2: values containing
|
||
* COLON, SEMICOLON or COMMA must be quoted; DQUOTE itself is not allowed in
|
||
* parameter values, so replace it.
|
||
*/
|
||
function icsParamValue(value: string): string {
|
||
const cleaned = value.replace(/[\r\n"]/g, "'");
|
||
return /[;:,]/.test(cleaned) ? `"${cleaned}"` : cleaned;
|
||
}
|
||
|
||
// JMAP RFC 8621 stores Message-IDs without angle brackets. Strip any that
|
||
// snuck in (e.g. when echoing values that originated from RFC 5322 headers).
|
||
function stripMessageIdBrackets(id: string): string {
|
||
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
|
||
}
|
||
|
||
// Generate a Message-ID for outgoing mail (bare msg-id, no angle brackets, per
|
||
// RFC 8621 §4.1.2.3). Without one the server synthesizes it from its OS
|
||
// hostname, which leaks internal names (e.g. @ip-10-0-12-97.ec2.internal) into
|
||
// headers — an anti-spam signal and an information disclosure. Use the sender's
|
||
// domain instead, matching what receivers expect a Message-ID to look like.
|
||
function generateMessageId(fromEmail: string): string {
|
||
const at = fromEmail.lastIndexOf('@');
|
||
const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost';
|
||
// 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;
|
||
// Consecutive keep-alive failures; failed pings skip upcoming ticks
|
||
// (30s -> 1m -> 2m -> ~5m) instead of hammering a down server (#588).
|
||
private pingFailureCount = 0;
|
||
private pingSkipRemaining = 0;
|
||
private accounts: Record<string, JMAPAccount> = {};
|
||
private eventSource: EventSource | null = null;
|
||
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
||
private lastStates: AccountStates = {};
|
||
private reconnecting = false;
|
||
private connectionChangeCallback: ((connected: boolean) => void) | null = null;
|
||
private rateLimitedUntil: number = 0;
|
||
private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null;
|
||
private rateLimitTimeout: NodeJS.Timeout | null = null;
|
||
// Per-account result of the keyword sort polarity probe (#718). `null` means
|
||
// the probe was inconclusive (every message read, or unread) and is retried
|
||
// after KEYWORD_SORT_PROBE_RETRY_MS; a determined value lives for the session.
|
||
private keywordSortPolarity = new Map<string, { value: KeywordSortPolarity | null; at: number }>();
|
||
private keywordSortProbes = new Map<string, Promise<KeywordSortPolarity>>();
|
||
// Accounts whose server refused a hasKeyword comparator with unsupportedSort.
|
||
private keywordSortUnsupported = new Set<string>();
|
||
private lastRateLimitNoticeAt: number = 0;
|
||
// Serialises the first calendar/contacts request per account so Stalwart's
|
||
// lazy default-calendar/address-book creation can't run twice (#907).
|
||
private firstTouchGate = new FirstTouchGate();
|
||
|
||
constructor(serverUrl: string, username: string, password: string) {
|
||
this.serverUrl = serverUrl.replace(/\/$/, '');
|
||
this.username = username;
|
||
this.password = password;
|
||
this.authHeader = `Basic ${btoa(`${username}:${password}`)}`;
|
||
}
|
||
|
||
static withBearer(
|
||
serverUrl: string,
|
||
accessToken: string,
|
||
username: string,
|
||
onTokenRefresh?: () => Promise<string | null>,
|
||
): JMAPClient {
|
||
const client = new JMAPClient(serverUrl, username, '');
|
||
client.authMode = 'bearer';
|
||
client.authHeader = `Bearer ${accessToken}`;
|
||
client.onTokenRefresh = onTokenRefresh;
|
||
return client;
|
||
}
|
||
|
||
updateAccessToken(token: string): void {
|
||
this.authHeader = `Bearer ${token}`;
|
||
}
|
||
|
||
async getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]> {
|
||
try {
|
||
const targetAccountId = accountId || this.accountId;
|
||
if (!emailsId || emailsId.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const emails: Email[] = [];
|
||
|
||
for (const batchIds of batched(emailsId, this.getMaxObjectsInGet())) {
|
||
const response = await this.request([
|
||
["Email/get", {
|
||
accountId: targetAccountId,
|
||
ids: batchIds,
|
||
properties: [...EMAIL_LIST_PROPERTIES],
|
||
}, "0"],
|
||
]);
|
||
|
||
const getResponse = response.methodResponses?.[0]?.[1];
|
||
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
|
||
emails.push(...((getResponse.list || []) as Email[]));
|
||
}
|
||
}
|
||
|
||
emails.sort((a: Email, b: Email) =>
|
||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||
);
|
||
|
||
if (accountId && accountId !== this.accountId) {
|
||
namespaceMailboxIds(emails, accountId);
|
||
}
|
||
|
||
return emails;
|
||
} catch (error) {
|
||
console.error('Failed to get specific emails:', error);
|
||
return [];
|
||
}
|
||
}
|
||
/** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */
|
||
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void {
|
||
this.authMode = 'bearer';
|
||
this.authHeader = `Bearer ${accessToken}`;
|
||
this.onTokenRefresh = onRefresh;
|
||
}
|
||
|
||
/**
|
||
* Enable TOTP re-authentication for basic-auth sessions.
|
||
* When a 401 is received, the callback is invoked to get a fresh TOTP code.
|
||
* The base password (without TOTP) is stored so we can construct new credentials.
|
||
*/
|
||
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void {
|
||
this.basePassword = basePassword;
|
||
this.onTotpRequired = callback;
|
||
}
|
||
|
||
/** Update basic-auth credentials with a new password (e.g. password$newTotp). */
|
||
updateBasicAuth(newPassword: string): void {
|
||
this.password = newPassword;
|
||
this.authHeader = `Basic ${btoa(`${this.username}:${newPassword}`)}`;
|
||
}
|
||
|
||
getAuthHeader(): string {
|
||
return this.authHeader;
|
||
}
|
||
|
||
getServerUrl(): string {
|
||
return this.serverUrl;
|
||
}
|
||
|
||
/**
|
||
* `fetch` with a deadline on the response *headers*.
|
||
*
|
||
* The timer is cleared as soon as the fetch settles, so it never touches the
|
||
* body: long-lived streams (SSE) and slow blob transfers keep working once
|
||
* the server has started answering. What it does catch is the stalled case -
|
||
* a connection that accepts the request and then goes silent - which fetch
|
||
* itself would leave pending indefinitely.
|
||
*
|
||
* A caller-supplied `init.signal` still aborts the request (and, for SSE, the
|
||
* stream) at any time; it is chained into the internal controller rather than
|
||
* replaced.
|
||
*/
|
||
private async timedFetch(
|
||
url: string,
|
||
init: Parameters<typeof fetch>[1],
|
||
headers: Record<string, string>,
|
||
timeoutMs: number,
|
||
): Promise<Response> {
|
||
const controller = new AbortController();
|
||
const external = init?.signal ?? undefined;
|
||
if (external) {
|
||
if (external.aborted) controller.abort(external.reason);
|
||
else external.addEventListener('abort', () => controller.abort(external.reason), { once: true });
|
||
}
|
||
|
||
// Tracked separately from the signal: the abort reason is what distinguishes
|
||
// "we gave up" from "the caller cancelled", and callers must be able to tell
|
||
// those apart (a cancelled send is not a failed send).
|
||
let timedOut = false;
|
||
const timer = setTimeout(() => {
|
||
timedOut = true;
|
||
controller.abort();
|
||
}, timeoutMs);
|
||
|
||
try {
|
||
return await fetch(url, { ...init, headers, signal: controller.signal });
|
||
} catch (error) {
|
||
if (timedOut) throw new RequestTimeoutError(timeoutMs);
|
||
throw error;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
private async authenticatedFetch(
|
||
url: string,
|
||
init?: Parameters<typeof fetch>[1],
|
||
opts?: { timeoutMs?: number },
|
||
): Promise<Response> {
|
||
// Short-circuit: if rate-limited, reject immediately without sending a request
|
||
if (this.isRateLimited()) {
|
||
const remaining = this.rateLimitedUntil - Date.now();
|
||
this.notifyRateLimitBlocked(remaining);
|
||
throw new RateLimitError(remaining);
|
||
}
|
||
|
||
const timeoutMs = opts?.timeoutMs ?? JMAPClient.REQUEST_TIMEOUT_MS;
|
||
const headers = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||
let response: Response;
|
||
|
||
try {
|
||
response = await this.timedFetch(url, init, headers, timeoutMs);
|
||
} catch (error) {
|
||
// Network error: retry once after brief delay (transient proxy/connection issues)
|
||
if (this.reconnecting) throw error;
|
||
// A timeout is NOT retried. The request may well have reached the server
|
||
// and been acted on - only the answer was lost - and these bodies are not
|
||
// idempotent: replaying an EmailSubmission/set would send the mail twice.
|
||
// Surface it instead so the caller can report a failure the user can act on.
|
||
if (error instanceof RequestTimeoutError) throw error;
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
response = await this.timedFetch(url, init, headers, timeoutMs);
|
||
}
|
||
|
||
// Handle 429 rate limiting - stop immediately, do not retry
|
||
if (response.status === 429) {
|
||
const retryAfterMs = JMAPClient.parseRetryAfter(response);
|
||
this.setRateLimited(retryAfterMs);
|
||
throw new RateLimitError(retryAfterMs);
|
||
}
|
||
|
||
if (response.status === 401) {
|
||
if (this.authMode === 'bearer' && this.onTokenRefresh) {
|
||
const newToken = await this.onTokenRefresh();
|
||
if (newToken) {
|
||
this.updateAccessToken(newToken);
|
||
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||
response = await this.timedFetch(url, init, retryHeaders, timeoutMs);
|
||
}
|
||
} else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) {
|
||
// JMAP session may have expired - re-establish and retry once
|
||
this.reconnecting = true;
|
||
try {
|
||
await this.refreshSession();
|
||
this.connectionChangeCallback?.(true);
|
||
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||
response = await this.timedFetch(url, init, retryHeaders, timeoutMs);
|
||
} catch {
|
||
// Session refresh failed - if TOTP was used, try re-auth with fresh TOTP
|
||
if (this.onTotpRequired && this.basePassword) {
|
||
try {
|
||
const newTotp = await this.onTotpRequired();
|
||
if (newTotp) {
|
||
this.updateBasicAuth(`${this.basePassword}$${newTotp}`);
|
||
await this.refreshSession();
|
||
this.connectionChangeCallback?.(true);
|
||
const retryHeaders = { ...init?.headers as Record<string, string>, 'Authorization': this.authHeader };
|
||
response = await this.timedFetch(url, init, retryHeaders, timeoutMs);
|
||
}
|
||
} catch {
|
||
// TOTP re-auth also failed - return original 401
|
||
}
|
||
}
|
||
} finally {
|
||
this.reconnecting = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
/**
|
||
* Fetch the JMAP session, transparently handling servers that redirect
|
||
* /.well-known/jmap to a canonical session URL (e.g. Stalwart → /jmap/session).
|
||
*
|
||
* Safari strips the Authorization header on cross-origin redirects even when
|
||
* the redirect destination is same-origin as the original request, and some
|
||
* reverse-auth proxies (e.g. Pangolin) admit the redirected request via a
|
||
* cookie without the Authorization header. Stalwart responds to an
|
||
* unauthenticated /jmap/session with 200 + empty accounts rather than 401,
|
||
* so the drop is silent and downstream parsing fails with "No mail account
|
||
* found in session".
|
||
*
|
||
* Detect that case (response.redirected, empty accounts, empty username)
|
||
* and retry directly against the final URL so we can re-send Authorization.
|
||
*/
|
||
private async fetchSessionResponse(): Promise<Response> {
|
||
const discoveryUrl = `${this.serverUrl}/.well-known/jmap`;
|
||
const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' });
|
||
if (!response.ok || !response.redirected) return response;
|
||
|
||
const peek = await response.clone().json().catch(() => null);
|
||
const hasAccounts = peek && Object.keys(peek.accounts || {}).length > 0;
|
||
const hasUsername = typeof peek?.username === 'string' && peek.username.length > 0;
|
||
if (hasAccounts || hasUsername) return response;
|
||
|
||
return fetch(response.url, {
|
||
method: 'GET',
|
||
headers: { 'Authorization': this.authHeader },
|
||
});
|
||
}
|
||
|
||
private async refreshSession(): Promise<void> {
|
||
const response = await this.fetchSessionResponse();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`Session refresh failed: ${response.status}`);
|
||
}
|
||
|
||
const session = await response.json();
|
||
this.rewriteSessionUrls(session);
|
||
this.session = session;
|
||
this.capabilities = session.capabilities || {};
|
||
this.apiUrl = session.apiUrl;
|
||
this.downloadUrl = session.downloadUrl;
|
||
this.accounts = session.accounts || {};
|
||
}
|
||
|
||
async connect(): Promise<void> {
|
||
this.intentionallyDisconnected = false;
|
||
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
|
||
|
||
try {
|
||
const sessionResponse = await this.fetchSessionResponse();
|
||
|
||
if (!sessionResponse.ok) {
|
||
if (sessionResponse.status === 401) {
|
||
throw new Error(this.authMode === 'bearer'
|
||
? 'Authentication failed - token may be expired'
|
||
: 'Invalid username or password');
|
||
}
|
||
if (sessionResponse.status === 402) {
|
||
try {
|
||
const body = await sessionResponse.json();
|
||
// Older Stalwart titled this "TOTP code required"; 0.16+ uses the
|
||
// generic "MFA code required" - accept either to trigger the prompt.
|
||
const title = body?.title?.toLowerCase() ?? '';
|
||
if (title.includes('totp') || title.includes('mfa')) {
|
||
throw new Error('TOTP_REQUIRED');
|
||
}
|
||
} catch (e) {
|
||
if (e instanceof Error && e.message === 'TOTP_REQUIRED') throw e;
|
||
}
|
||
}
|
||
throw new Error(`Failed to get session: ${sessionResponse.status}`);
|
||
}
|
||
|
||
const session = await sessionResponse.json();
|
||
this.rewriteSessionUrls(session);
|
||
|
||
this.session = session;
|
||
this.capabilities = session.capabilities || {};
|
||
this.apiUrl = session.apiUrl;
|
||
this.downloadUrl = session.downloadUrl;
|
||
this.accounts = session.accounts || {};
|
||
|
||
const mailAccount = session.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||
const fallbackAccount = Object.keys(this.accounts)[0];
|
||
this.accountId = mailAccount || fallbackAccount;
|
||
|
||
if (!this.accountId) {
|
||
throw new Error('No mail account found in session');
|
||
}
|
||
|
||
this.startKeepAlive();
|
||
} catch (error) {
|
||
if (error instanceof TypeError && (
|
||
error.message === 'Failed to fetch' ||
|
||
error.message.includes('NetworkError') ||
|
||
error.message === 'Load failed' ||
|
||
error.message === 'cancelled'
|
||
)) {
|
||
let serverReachable = false;
|
||
try {
|
||
await fetch(sessionUrl, { mode: 'no-cors' });
|
||
serverReachable = true;
|
||
} catch { /* genuinely unreachable */ }
|
||
if (serverReachable) {
|
||
throw new Error('CORS_ERROR');
|
||
}
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
private startKeepAlive(): void {
|
||
this.stopKeepAlive();
|
||
|
||
this.pingInterval = setInterval(async () => {
|
||
if (this.intentionallyDisconnected) return;
|
||
// Skip ping while rate-limited to avoid compounding auth failures
|
||
if (this.isRateLimited()) return;
|
||
// Back off while the server is down: each consecutive failure skips
|
||
// more ticks (30s -> 1m -> 2m -> ~5m) instead of retrying flat-out.
|
||
if (this.pingSkipRemaining > 0) {
|
||
this.pingSkipRemaining--;
|
||
return;
|
||
}
|
||
try {
|
||
await this.ping();
|
||
this.pingFailureCount = 0;
|
||
this.connectionChangeCallback?.(true);
|
||
} catch (error) {
|
||
if (error instanceof RateLimitError) {
|
||
return;
|
||
}
|
||
// A sign-out while the ping was in flight - stay down.
|
||
if (this.intentionallyDisconnected) return;
|
||
this.pingFailureCount++;
|
||
this.pingSkipRemaining = Math.min(2 ** this.pingFailureCount, 10) - 1;
|
||
console.error('Keep-alive ping failed:', error);
|
||
this.connectionChangeCallback?.(false);
|
||
try {
|
||
await this.reconnect();
|
||
this.pingFailureCount = 0;
|
||
this.pingSkipRemaining = 0;
|
||
this.connectionChangeCallback?.(true);
|
||
} catch (reconnectError) {
|
||
console.error('Reconnection failed:', reconnectError);
|
||
}
|
||
}
|
||
}, 30_000);
|
||
}
|
||
|
||
private stopKeepAlive(): void {
|
||
if (this.pingInterval) {
|
||
clearInterval(this.pingInterval);
|
||
this.pingInterval = null;
|
||
}
|
||
}
|
||
|
||
async ping(): Promise<void> {
|
||
if (!this.apiUrl) {
|
||
throw new Error('Not connected');
|
||
}
|
||
|
||
const now = Date.now();
|
||
const response = await this.request([
|
||
["Core/echo", { ping: "pong" }, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] !== "Core/echo") {
|
||
throw new Error('Ping failed');
|
||
}
|
||
this.lastPingTime = now;
|
||
}
|
||
|
||
async reconnect(): Promise<void> {
|
||
if (this.intentionallyDisconnected) return;
|
||
await this.connect();
|
||
}
|
||
|
||
disconnect(): void {
|
||
this.intentionallyDisconnected = true;
|
||
this.stopKeepAlive();
|
||
this.closePushNotifications();
|
||
if (this.rateLimitTimeout) {
|
||
clearTimeout(this.rateLimitTimeout);
|
||
this.rateLimitTimeout = null;
|
||
}
|
||
this.apiUrl = "";
|
||
this.accountId = "";
|
||
this.session = null;
|
||
this.capabilities = {};
|
||
}
|
||
|
||
private rewriteSessionUrl(url: string): string {
|
||
if (!url) return url;
|
||
try {
|
||
const { origin } = new URL(this.serverUrl);
|
||
if (!/^(https?:)?\/\//i.test(url)) {
|
||
return origin + (url.startsWith('/') ? url : '/' + url);
|
||
}
|
||
const pathStart = url.indexOf('/', url.indexOf('//') + 2);
|
||
return origin + (pathStart === -1 ? '' : url.slice(pathStart));
|
||
} catch {
|
||
return url;
|
||
}
|
||
}
|
||
|
||
private rewriteSessionUrls(session: JMAPSession): void {
|
||
session.apiUrl = this.rewriteSessionUrl(session.apiUrl);
|
||
session.downloadUrl = this.rewriteSessionUrl(session.downloadUrl);
|
||
if (session.uploadUrl) {
|
||
session.uploadUrl = this.rewriteSessionUrl(session.uploadUrl);
|
||
}
|
||
if (session.eventSourceUrl) {
|
||
session.eventSourceUrl = this.rewriteSessionUrl(session.eventSourceUrl);
|
||
}
|
||
}
|
||
|
||
private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise<JMAPResponse> {
|
||
if (!this.apiUrl) {
|
||
throw new Error('Not connected. Call connect() first.');
|
||
}
|
||
|
||
const requestBody = {
|
||
using: using || (hasSubmissionMethod(methodCalls) ? [...SUBMISSION_USING] : ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"]),
|
||
methodCalls,
|
||
};
|
||
const body = JSON.stringify(requestBody);
|
||
|
||
const apiUrl = this.apiUrl;
|
||
for (let attempt = 0; ; attempt++) {
|
||
const response = await this.firstTouchGate.run(methodCalls, () =>
|
||
this.authenticatedFetch(apiUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body,
|
||
}),
|
||
);
|
||
|
||
const responseText = await response.text();
|
||
|
||
if (!response.ok) {
|
||
// Stalwart caps parallel API requests per user (maxConcurrentRequests,
|
||
// default 4). One push event fans out into several refreshes at once
|
||
// (folders, tag counts, thread counts, the list) - multiplied by open
|
||
// tabs - so the ceiling is reached in ordinary use. The refusal comes
|
||
// before any method runs, so back off briefly and replay instead of
|
||
// failing a refresh outright (which used to blank the folder tree, #780).
|
||
if (attempt < CONCURRENT_REQUEST_RETRY_DELAYS_MS.length && isConcurrentRequestRefusal(response.status, responseText)) {
|
||
const base = CONCURRENT_REQUEST_RETRY_DELAYS_MS[attempt];
|
||
await new Promise(r => setTimeout(r, base + Math.random() * base));
|
||
continue;
|
||
}
|
||
console.error('Request failed:', response.status, responseText);
|
||
throw new Error(`Request failed: ${response.status} - ${responseText.substring(0, 200)}`);
|
||
}
|
||
|
||
try {
|
||
return JSON.parse(responseText);
|
||
} catch {
|
||
console.error('Failed to parse response:', responseText);
|
||
throw new Error('Invalid JSON response from server');
|
||
}
|
||
}
|
||
}
|
||
|
||
async getQuota(): Promise<{ used: number; total: number } | null> {
|
||
if (!this.supportsQuota()) return null;
|
||
|
||
try {
|
||
const response = await this.request([
|
||
["Quota/get", {
|
||
accountId: this.accountId,
|
||
}, "0"]
|
||
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:quota"]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Quota/get") {
|
||
const quotas = (response.methodResponses[0][1].list || []) as JMAPQuota[];
|
||
const coversMail = (q: JMAPQuota) =>
|
||
!q.types?.length || q.types.some((t) => t === "Email" || t === "Mail");
|
||
// storage quotas use resourceType "octets" (e.g. Stalwart, with
|
||
// scope "account"); fall back to the pre-RFC "mail" shape for older servers.
|
||
const mailQuota =
|
||
quotas.find((q) => q.resourceType === "octets" && coversMail(q)) ||
|
||
quotas.find((q) => q.resourceType === "mail" || q.scope === "mail");
|
||
|
||
if (mailQuota) {
|
||
return {
|
||
used: mailQuota.used ?? 0,
|
||
total: mailQuota.hardLimit ?? mailQuota.limit ?? 0
|
||
};
|
||
}
|
||
}
|
||
|
||
return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async getMailboxes(accountId?: string): Promise<Mailbox[]> {
|
||
const acctId = accountId || this.accountId;
|
||
try {
|
||
const response = await this.request([
|
||
["Mailbox/get", { accountId: acctId }, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||
|
||
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${acctId}`);
|
||
|
||
// Warn if response might be truncated
|
||
const maxObjects = this.getMaxObjectsInGet();
|
||
if (rawMailboxes.length >= maxObjects) {
|
||
debug.warn('jmap',
|
||
`[JMAP Mailbox] Response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
||
`Some mailboxes may be missing - nested folders could appear orphaned at root level.`
|
||
);
|
||
}
|
||
|
||
// Log parentId references to detect potential orphans
|
||
const returnedIds = new Set(rawMailboxes.map(mb => mb.id));
|
||
const missingParents = rawMailboxes.filter(mb => mb.parentId && !returnedIds.has(mb.parentId));
|
||
if (missingParents.length > 0) {
|
||
debug.warn('jmap',
|
||
`[JMAP Mailbox] ${missingParents.length} mailbox(es) reference parentId not in response (will be orphaned):`,
|
||
missingParents.map(mb => ({ id: mb.id, name: mb.name, parentId: mb.parentId }))
|
||
);
|
||
}
|
||
|
||
return rawMailboxes.map((mb) => ({
|
||
id: mb.id,
|
||
originalId: undefined,
|
||
name: mb.name,
|
||
parentId: mb.parentId || undefined,
|
||
role: mb.role || undefined,
|
||
sortOrder: mb.sortOrder ?? 0,
|
||
totalEmails: mb.totalEmails ?? 0,
|
||
unreadEmails: mb.unreadEmails ?? 0,
|
||
totalThreads: mb.totalThreads ?? 0,
|
||
unreadThreads: mb.unreadThreads ?? 0,
|
||
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
||
isSubscribed: mb.isSubscribed ?? true,
|
||
accountId: acctId,
|
||
accountName: this.accounts[acctId]?.name || this.username,
|
||
isShared: acctId !== this.accountId,
|
||
}) as Mailbox);
|
||
}
|
||
|
||
throw new Error('Unexpected response format');
|
||
} catch (error) {
|
||
// Never substitute a placeholder here. A refused request (the server's
|
||
// maxConcurrentRequests ceiling during a push-driven refresh burst) used
|
||
// to yield a lone synthetic "Inbox" with a made-up id, and the store then
|
||
// replaced the real folder tree with it: the sidebar showed only Inbox
|
||
// (empty, since the id matched nothing) until a reload (#780). Callers
|
||
// keep what they already have, or surface the failure.
|
||
console.error('Failed to get mailboxes:', error);
|
||
throw error instanceof Error ? error : new Error('Failed to get mailboxes');
|
||
}
|
||
}
|
||
|
||
async getAllMailboxes(): Promise<Mailbox[]> {
|
||
return (await this.getAllMailboxesWithState()).mailboxes;
|
||
}
|
||
|
||
/**
|
||
* Like getAllMailboxes, but also returns the Mailbox collection `state`
|
||
* (RFC 8620 §5.1) of every account fetched, keyed by accountId. The store
|
||
* remembers those states so a later push can be resolved with
|
||
* Mailbox/changes (delta) instead of re-fetching every folder tree.
|
||
*/
|
||
async getAllMailboxesWithState(): Promise<{ mailboxes: Mailbox[]; states: Record<string, string> }> {
|
||
try {
|
||
const allMailboxes: Mailbox[] = [];
|
||
const states: Record<string, string> = {};
|
||
const accountIds = Object.keys(this.accounts);
|
||
|
||
if (accountIds.length === 0) {
|
||
return { mailboxes: await this.getMailboxes(), states };
|
||
}
|
||
|
||
let fetchFailed = false;
|
||
|
||
for (const accountId of accountIds) {
|
||
const account = this.accounts[accountId];
|
||
const isPrimary = accountId === this.accountId;
|
||
|
||
try {
|
||
const response = await this.request([
|
||
["Mailbox/get", {
|
||
accountId: accountId,
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
|
||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||
const mailboxState = (response.methodResponses[0][1] as { state?: unknown }).state;
|
||
if (typeof mailboxState === 'string') {
|
||
states[accountId] = mailboxState;
|
||
}
|
||
|
||
debug.log('jmap', `[JMAP Mailbox] getAllMailboxes: account ${accountId} returned ${rawMailboxes.length} mailboxes (isPrimary: ${isPrimary})`);
|
||
|
||
// Warn if response might be truncated
|
||
const maxObjects = this.getMaxObjectsInGet();
|
||
if (rawMailboxes.length >= maxObjects) {
|
||
debug.warn('jmap',
|
||
`[JMAP Mailbox] Account ${accountId}: response contains ${rawMailboxes.length} mailboxes which equals maxObjectsInGet (${maxObjects}). ` +
|
||
`Some mailboxes may be missing.`
|
||
);
|
||
}
|
||
|
||
const mailboxes = rawMailboxes.map((mb) => ({
|
||
id: isPrimary ? mb.id : `${accountId}:${mb.id}`,
|
||
originalId: mb.id,
|
||
name: mb.name,
|
||
parentId: mb.parentId ? (isPrimary ? mb.parentId : `${accountId}:${mb.parentId}`) : undefined,
|
||
role: mb.role || undefined,
|
||
sortOrder: mb.sortOrder ?? 0,
|
||
totalEmails: mb.totalEmails ?? 0,
|
||
unreadEmails: mb.unreadEmails ?? 0,
|
||
totalThreads: mb.totalThreads ?? 0,
|
||
unreadThreads: mb.unreadThreads ?? 0,
|
||
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
||
isSubscribed: mb.isSubscribed ?? true,
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}) as Mailbox);
|
||
|
||
allMailboxes.push(...mailboxes);
|
||
}
|
||
} catch (error) {
|
||
fetchFailed = true;
|
||
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
// If every account fetch failed (e.g. a transient maxConcurrentRequests
|
||
// limit during a burst of deletes) we have an empty list that is NOT a
|
||
// real "this account has no mailboxes" result. Throwing lets the caller's
|
||
// catch preserve the existing folder list instead of clobbering it with
|
||
// [] — which would leave the sidebar stuck on "Loading mailboxes...".
|
||
if (allMailboxes.length === 0 && fetchFailed) {
|
||
throw new Error('Failed to fetch mailboxes for all accounts');
|
||
}
|
||
|
||
return { mailboxes: allMailboxes, states };
|
||
} catch (error) {
|
||
// No placeholder fallback (see getMailboxes): the caller keeps its
|
||
// current list rather than showing a folder tree the server never sent.
|
||
console.error("Failed to fetch all mailboxes:", error);
|
||
throw error instanceof Error ? error : new Error('Failed to fetch mailboxes');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Maps a raw JMAP Mailbox into the store's shape. Folders of a delegated
|
||
* (non-primary) account get namespaced ids (`accountId:id`) exactly like
|
||
* getAllMailboxes produces them, so a delta-patched folder matches the row
|
||
* it replaces.
|
||
*/
|
||
private mapRawMailbox(mb: JMAPMailbox, accountId: string): Mailbox {
|
||
const isPrimary = accountId === this.accountId;
|
||
const account = this.accounts[accountId];
|
||
return {
|
||
id: isPrimary ? mb.id : `${accountId}:${mb.id}`,
|
||
originalId: mb.id,
|
||
name: mb.name,
|
||
parentId: mb.parentId ? (isPrimary ? mb.parentId : `${accountId}:${mb.parentId}`) : undefined,
|
||
role: mb.role || undefined,
|
||
sortOrder: mb.sortOrder ?? 0,
|
||
totalEmails: mb.totalEmails ?? 0,
|
||
unreadEmails: mb.unreadEmails ?? 0,
|
||
totalThreads: mb.totalThreads ?? 0,
|
||
unreadThreads: mb.unreadThreads ?? 0,
|
||
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
|
||
isSubscribed: mb.isSubscribed ?? true,
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
} as Mailbox;
|
||
}
|
||
|
||
/**
|
||
* Fetches only the given mailboxes (Mailbox/get with `ids`), mapped like
|
||
* getAllMailboxes. Used to patch the folders a Mailbox/changes response
|
||
* reported as updated without re-fetching the whole tree.
|
||
*/
|
||
async getMailboxesByIds(ids: string[], accountId?: string): Promise<Mailbox[]> {
|
||
const acctId = accountId || this.accountId;
|
||
if (ids.length === 0) return [];
|
||
const mailboxes: Mailbox[] = [];
|
||
for (const batchIds of batched(ids, this.getMaxObjectsInGet())) {
|
||
const response = await this.request([
|
||
["Mailbox/get", { accountId: acctId, ids: batchIds }, "0"],
|
||
]);
|
||
if (response.methodResponses?.[0]?.[0] !== "Mailbox/get") {
|
||
throw new Error('Unexpected response format');
|
||
}
|
||
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
|
||
mailboxes.push(...rawMailboxes.map((mb) => this.mapRawMailbox(mb, acctId)));
|
||
}
|
||
return mailboxes;
|
||
}
|
||
|
||
/**
|
||
* Generic `Foo/changes` (RFC 8620 §5.2). Returns null when the server can
|
||
* no longer compute a delta from `sinceState` (`cannotCalculateChanges`,
|
||
* a too-old state, or any other error) - the caller then falls back to a
|
||
* full re-fetch. `hasMoreChanges` is surfaced rather than paged through:
|
||
* a delta that large is cheaper to resolve with a fresh query.
|
||
*/
|
||
private async getCollectionChanges(type: 'Email' | 'Mailbox', sinceState: string, accountId?: string, maxChanges?: number): Promise<CollectionChanges | null> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
try {
|
||
const response = await this.request([
|
||
[`${type}/changes`, {
|
||
accountId: targetAccountId,
|
||
sinceState,
|
||
...(maxChanges ? { maxChanges } : {}),
|
||
}, "0"],
|
||
]);
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== `${type}/changes` || !result) {
|
||
return null;
|
||
}
|
||
const r = result as {
|
||
oldState?: string;
|
||
newState?: string;
|
||
hasMoreChanges?: boolean;
|
||
created?: string[];
|
||
updated?: string[];
|
||
destroyed?: string[];
|
||
updatedProperties?: string[] | null;
|
||
};
|
||
if (typeof r.newState !== 'string') return null;
|
||
return {
|
||
oldState: r.oldState ?? sinceState,
|
||
newState: r.newState,
|
||
hasMoreChanges: r.hasMoreChanges === true,
|
||
created: r.created ?? [],
|
||
updated: r.updated ?? [],
|
||
destroyed: r.destroyed ?? [],
|
||
updatedProperties: Array.isArray(r.updatedProperties) ? r.updatedProperties : null,
|
||
};
|
||
} catch (error) {
|
||
debug.log('jmap', `[JMAP] ${type}/changes since ${sinceState} unavailable, falling back to a full fetch`, error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Email/changes since `sinceState` for the account (see getCollectionChanges). */
|
||
getEmailChanges(sinceState: string, accountId?: string, maxChanges?: number): Promise<CollectionChanges | null> {
|
||
return this.getCollectionChanges('Email', sinceState, accountId, maxChanges);
|
||
}
|
||
|
||
/** Mailbox/changes since `sinceState` for the account (see getCollectionChanges). */
|
||
getMailboxChanges(sinceState: string, accountId?: string, maxChanges?: number): Promise<CollectionChanges | null> {
|
||
return this.getCollectionChanges('Mailbox', sinceState, accountId, maxChanges);
|
||
}
|
||
|
||
/**
|
||
* Sort properties the server advertises for Email/query on this account
|
||
* (RFC 8621 §1.3 `emailQuerySortOptions`), or null when it doesn't say.
|
||
*/
|
||
getEmailQuerySortOptions(accountId?: string): string[] | null {
|
||
const id = accountId || this.accountId;
|
||
const caps = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:mail"] as
|
||
| { emailQuerySortOptions?: unknown }
|
||
| undefined;
|
||
const options = caps?.emailQuerySortOptions;
|
||
return Array.isArray(options) ? options.filter((o): o is string => typeof o === "string") : null;
|
||
}
|
||
|
||
/**
|
||
* Whether keyword comparators (hasKeyword) can be sent to this account's
|
||
* server: not when it advertises a sort-option list without them, and not
|
||
* after it has refused one with `unsupportedSort`. Unknown counts as yes.
|
||
*/
|
||
private keywordSortSupported(accountId: string): boolean {
|
||
if (this.keywordSortUnsupported.has(accountId)) return false;
|
||
const options = this.getEmailQuerySortOptions(accountId);
|
||
return options === null || options.includes("hasKeyword");
|
||
}
|
||
|
||
/**
|
||
* Determines, once per account and session, which way the server reads
|
||
* `isAscending` on hasKeyword comparators (see KeywordSortPolarity in
|
||
* lib/message-list-order.ts). Two one-item queries with opposite
|
||
* directions: if they return different messages both groups exist and the
|
||
* keywords of the ascending winner settle the polarity; if they return the
|
||
* same message the mailbox is homogeneous and the probe is inconclusive.
|
||
* Any failure falls back to the RFC reading without caching a verdict.
|
||
*/
|
||
private resolveKeywordSortPolarity(accountId: string): Promise<KeywordSortPolarity> {
|
||
const cached = this.keywordSortPolarity.get(accountId);
|
||
if (cached && (cached.value !== null || Date.now() - cached.at < KEYWORD_SORT_PROBE_RETRY_MS)) {
|
||
return Promise.resolve(cached.value ?? "rfc");
|
||
}
|
||
const pending = this.keywordSortProbes.get(accountId);
|
||
if (pending) return pending;
|
||
const probe = this.probeKeywordSortPolarity(accountId)
|
||
.then((value) => {
|
||
this.keywordSortPolarity.set(accountId, { value, at: Date.now() });
|
||
return value ?? "rfc";
|
||
})
|
||
.catch((error) => {
|
||
debug.warn("jmap", "Keyword sort polarity probe failed:", error);
|
||
this.keywordSortPolarity.set(accountId, { value: null, at: Date.now() });
|
||
return "rfc" as const;
|
||
})
|
||
.finally(() => {
|
||
this.keywordSortProbes.delete(accountId);
|
||
});
|
||
this.keywordSortProbes.set(accountId, probe);
|
||
return probe;
|
||
}
|
||
|
||
private async probeKeywordSortPolarity(accountId: string): Promise<KeywordSortPolarity | null> {
|
||
const sortFor = (isAscending: boolean) => [
|
||
{ property: "hasKeyword", keyword: "$seen", isAscending },
|
||
{ property: "receivedAt", isAscending: false },
|
||
];
|
||
const response = await this.request([
|
||
["Email/query", { accountId, sort: sortFor(true), limit: 1 }, "asc"],
|
||
["Email/query", { accountId, sort: sortFor(false), limit: 1 }, "desc"],
|
||
["Email/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "asc", name: "Email/query", path: "/ids" },
|
||
properties: ["id", "keywords"],
|
||
}, "get"],
|
||
]);
|
||
const [ascMethod, ascResult] = response.methodResponses?.[0] ?? [];
|
||
if (ascMethod === "error") {
|
||
if ((ascResult as { type?: string })?.type === "unsupportedSort") {
|
||
this.keywordSortUnsupported.add(accountId);
|
||
return "rfc";
|
||
}
|
||
throw new Error((ascResult as { description?: string })?.description || "Email/query failed");
|
||
}
|
||
const ascIds = ((ascResult as { ids?: string[] })?.ids) ?? [];
|
||
const descIds = ((response.methodResponses?.[1]?.[1] as { ids?: string[] })?.ids) ?? [];
|
||
if (ascIds.length === 0 || descIds.length === 0 || ascIds[0] === descIds[0]) return null;
|
||
const first = ((response.methodResponses?.[2]?.[1] as { list?: Email[] })?.list ?? [])[0];
|
||
if (!first || first.id !== ascIds[0]) return null;
|
||
// Ascending put a $seen message first: the server sorts has-keyword first
|
||
// on isAscending: true, the inverse of the RFC's false < true reading.
|
||
return first.keywords?.$seen ? "inverted" : "rfc";
|
||
}
|
||
|
||
/** The Email/query sort for a folder view, with the server's polarity applied. */
|
||
private async buildListSort(accountId: string, pinnedFirst: boolean, order: SortLevel[]): Promise<{ sort: ReturnType<typeof buildEmailSort>; keywordSortSupported: boolean }> {
|
||
if (!hasKeywordLevels(order, pinnedFirst)) {
|
||
return { sort: buildEmailSort(order), keywordSortSupported: true };
|
||
}
|
||
if (!this.keywordSortSupported(accountId)) {
|
||
return { sort: buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }), keywordSortSupported: false };
|
||
}
|
||
const polarity = await this.resolveKeywordSortPolarity(accountId);
|
||
return { sort: buildEmailSort(order, { pinnedFirst, polarity }), keywordSortSupported: true };
|
||
}
|
||
|
||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number, state?: string }> {
|
||
try {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const simple: { inMailbox?: string; hasKeyword?: string } = {};
|
||
if (mailboxId) {
|
||
simple.inMailbox = mailboxId;
|
||
}
|
||
if (hasKeyword) {
|
||
simple.hasKeyword = hasKeyword;
|
||
}
|
||
// `extraFilter` is an arbitrary FilterCondition/FilterOperator ANDed
|
||
// into the view - the message-list category tabs' search contract.
|
||
const filter: Record<string, unknown> = extraFilter
|
||
? {
|
||
operator: "AND",
|
||
conditions: [
|
||
...(Object.keys(simple).length > 0 ? [simple] : []),
|
||
extraFilter,
|
||
],
|
||
}
|
||
: simple;
|
||
// Pinned-first and the configured list order (#718) use the hasKeyword
|
||
// sort comparator (RFC 8621 §4.4.2); every page of a view must use the
|
||
// same sort or pagination tears, so the sort is built from settings the
|
||
// same way on the first page, load-more and the push refresh.
|
||
const built = await this.buildListSort(targetAccountId, pinnedFirst === true, order);
|
||
const query = (sort: ReturnType<typeof buildEmailSort>) => this.request([
|
||
["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter,
|
||
sort,
|
||
limit,
|
||
position,
|
||
calculateTotal: true,
|
||
}, "0"],
|
||
["Email/get", {
|
||
accountId: targetAccountId,
|
||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||
properties: [...EMAIL_LIST_PROPERTIES],
|
||
}, "1"],
|
||
]);
|
||
|
||
let response = await query(built.sort);
|
||
// A server that advertises nothing but refuses hasKeyword fails the whole
|
||
// query. Remember that and retry once without the keyword comparators
|
||
// rather than showing an empty folder.
|
||
if (
|
||
built.keywordSortSupported &&
|
||
response.methodResponses?.[0]?.[0] === "error" &&
|
||
(response.methodResponses[0][1] as { type?: string })?.type === "unsupportedSort"
|
||
) {
|
||
this.keywordSortUnsupported.add(targetAccountId);
|
||
response = await query(buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }));
|
||
}
|
||
|
||
const queryResponse = response.methodResponses?.[0]?.[1];
|
||
const getResponse = response.methodResponses?.[1]?.[1];
|
||
|
||
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
|
||
const emails = (getResponse.list || []) as Email[];
|
||
// Sort client-side as safety net - some servers may not honour
|
||
// the query sort for large mailboxes without additional filters.
|
||
// Must mirror the query sort, or it would undo the configured order.
|
||
emails.sort(compareEmails(order, { pinnedFirst }));
|
||
const total = queryResponse?.total || 0;
|
||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
||
|
||
if (accountId && accountId !== this.accountId) {
|
||
namespaceMailboxIds(emails, accountId);
|
||
}
|
||
|
||
// The Email collection state this page was read at; the store keeps
|
||
// it so a later push can be applied with Email/changes.
|
||
const state = typeof getResponse.state === 'string' ? getResponse.state : undefined;
|
||
return { emails, hasMore, total, state };
|
||
}
|
||
|
||
return { emails: [], hasMore: false, total: 0 };
|
||
} catch (error) {
|
||
console.error('Failed to get emails:', error);
|
||
return { emails: [], hasMore: false, total: 0 };
|
||
}
|
||
}
|
||
|
||
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
||
const allEmails: Email[] = [];
|
||
let position = 0;
|
||
const batchSize = 100;
|
||
|
||
while (true) {
|
||
const { emails, hasMore } = await this.getEmails(mailboxId, undefined, batchSize, position);
|
||
allEmails.push(...emails);
|
||
if (!hasMore || emails.length === 0) break;
|
||
position += emails.length;
|
||
}
|
||
|
||
return allEmails;
|
||
}
|
||
|
||
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
||
if (tagIds.length === 0) return {};
|
||
const result: Record<string, { total: number; unread: number }> = {};
|
||
|
||
const CALLS_PER_TAG = 2;
|
||
const perRequest = itemsPerRequest(this.getMaxCallsInRequest(), CALLS_PER_TAG);
|
||
|
||
for (const batch of batched(tagIds, perRequest)) {
|
||
try {
|
||
const methodCalls: JMAPMethodCall[] = [];
|
||
for (let i = 0; i < batch.length; i++) {
|
||
const keyword = `$label:${batch[i]}`;
|
||
// Total count for this tag
|
||
methodCalls.push(["Email/query", {
|
||
accountId: this.accountId,
|
||
filter: { hasKeyword: keyword },
|
||
limit: 0,
|
||
calculateTotal: true,
|
||
}, `total_${i}`]);
|
||
// Unread count for this tag
|
||
methodCalls.push(["Email/query", {
|
||
accountId: this.accountId,
|
||
filter: {
|
||
operator: "AND",
|
||
conditions: [
|
||
{ hasKeyword: keyword },
|
||
{ notKeyword: "$seen" },
|
||
],
|
||
},
|
||
limit: 0,
|
||
calculateTotal: true,
|
||
}, `unread_${i}`]);
|
||
}
|
||
|
||
const response = await this.request(methodCalls);
|
||
|
||
for (let i = 0; i < batch.length; i++) {
|
||
const totalResp = response.methodResponses?.[i * 2]?.[1];
|
||
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
|
||
result[batch[i]] = {
|
||
total: totalResp?.total ?? 0,
|
||
unread: unreadResp?.total ?? 0,
|
||
};
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to get tag counts:', error);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Every keyword the account's messages actually carry, and how many messages
|
||
* carry each.
|
||
*
|
||
* JMAP has no "list the keywords in use" call - a keyword exists only as a
|
||
* property of the messages bearing it - so the only way to find them is to
|
||
* walk the message list and union what turns up. Each page is one request:
|
||
* an Email/query for the next slice of ids and an Email/get back-referencing
|
||
* it, asking for nothing but `keywords`.
|
||
*
|
||
* Counting here rather than following up with `getTagCounts` costs nothing
|
||
* extra - the messages are already in hand - and counts whatever spelling the
|
||
* keyword actually has, which a `$label:`-shaped count query cannot do for a
|
||
* keyword still written the legacy way. The trade is that a count is only
|
||
* over the messages walked, so it is a floor rather than a total whenever
|
||
* `complete` is false.
|
||
*
|
||
* The walk is capped at `limit` messages because an account can hold far more
|
||
* mail than is worth paging through for this; `complete` says whether the cap
|
||
* (or an abort) cut the scan short, so a caller can say so rather than
|
||
* present a partial answer as the whole truth. Reading is all this does, and
|
||
* it stops at the first failed page instead of retrying - a scan that ends
|
||
* early is reported as incomplete, which is exactly what it is.
|
||
*/
|
||
async discoverKeywords(options?: {
|
||
limit?: number;
|
||
onProgress?: (scanned: number, total: number) => void;
|
||
signal?: AbortSignal;
|
||
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }> {
|
||
const cap = Math.max(0, options?.limit ?? DEFAULT_KEYWORD_SCAN_LIMIT);
|
||
const pageSize = Math.max(1, Math.min(500, this.getMaxObjectsInGet()));
|
||
const keywords: Record<string, number> = {};
|
||
let scanned = 0;
|
||
let total = 0;
|
||
let complete = false;
|
||
|
||
while (scanned < cap) {
|
||
if (options?.signal?.aborted) break;
|
||
|
||
try {
|
||
const response = await this.request([
|
||
["Email/query", {
|
||
accountId: this.accountId,
|
||
sort: [{ property: "receivedAt", isAscending: false }],
|
||
limit: Math.min(pageSize, cap - scanned),
|
||
position: scanned,
|
||
calculateTotal: scanned === 0,
|
||
}, "0"],
|
||
["Email/get", {
|
||
accountId: this.accountId,
|
||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||
properties: ["keywords"],
|
||
}, "1"],
|
||
]);
|
||
|
||
const queryResponse = response.methodResponses?.[0]?.[1];
|
||
const getResponse = response.methodResponses?.[1]?.[1];
|
||
const ids: string[] = queryResponse?.ids || [];
|
||
if (scanned === 0) total = queryResponse?.total ?? 0;
|
||
|
||
const list = (getResponse?.list || []) as Array<{ keywords?: Record<string, boolean> }>;
|
||
for (const email of list) {
|
||
for (const [keyword, isSet] of Object.entries(email.keywords || {})) {
|
||
if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1;
|
||
}
|
||
}
|
||
|
||
// Page by what the query returned, not by what the get did: a message
|
||
// destroyed between the two lands in `notFound` and would otherwise
|
||
// shift every later page by one and skip a message per gap.
|
||
scanned += ids.length;
|
||
options?.onProgress?.(scanned, Math.max(total, scanned));
|
||
|
||
// A short page is the end of the list, however much `total` claimed.
|
||
if (ids.length === 0 || ids.length < Math.min(pageSize, cap - (scanned - ids.length))) {
|
||
complete = true;
|
||
break;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to scan keywords:', error);
|
||
break;
|
||
}
|
||
}
|
||
|
||
return { keywords, scanned, total: Math.max(total, scanned), complete };
|
||
}
|
||
|
||
/**
|
||
* Extension-facing keyword enumeration.
|
||
*
|
||
* A JMAP server can advertise a narrow capability that enumerates all cached
|
||
* keywords in one request, including provider labels with no messages.
|
||
* Servers without the capability retain the existing message walk as a
|
||
* transparent fallback.
|
||
*/
|
||
async getKeywords(options?: {
|
||
limit?: number;
|
||
onProgress?: (scanned: number, total: number) => void;
|
||
signal?: AbortSignal;
|
||
}): Promise<KeywordDiscoveryResult> {
|
||
const supportsKeywordGet =
|
||
this.hasCapability(KEYWORDS_CAPABILITY) &&
|
||
this.hasAccountCapability(KEYWORDS_CAPABILITY);
|
||
|
||
if (!supportsKeywordGet) {
|
||
const scan = await this.discoverKeywords(options);
|
||
const labels: KeywordInfo[] = Object.entries(scan.keywords).map(([id, total]) => ({
|
||
id,
|
||
name: id.startsWith('$label:') ? id.slice('$label:'.length) : id,
|
||
color: null,
|
||
total,
|
||
unread: 0,
|
||
isProviderLabel: false,
|
||
source: 'message',
|
||
}));
|
||
return { ...scan, labels };
|
||
}
|
||
|
||
if (options?.signal?.aborted) {
|
||
return { keywords: {}, labels: [], scanned: 0, total: 0, complete: false };
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["Keyword/get", { accountId: this.accountId }, "0"]],
|
||
["urn:ietf:params:jmap:core", KEYWORDS_CAPABILITY],
|
||
);
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== 'Keyword/get' || !Array.isArray(result?.list)) {
|
||
const description = typeof result?.description === 'string' ? `: ${result.description}` : '';
|
||
throw new Error(`JMAP Keyword/get failed${description}`);
|
||
}
|
||
|
||
const labels = (result.list as KeywordInfo[]).map((item) => ({ ...item }));
|
||
const keywords = Object.fromEntries(labels.map((item) => [item.id, item.total]));
|
||
const total = typeof result.totalEmails === 'number' ? result.totalEmails : 0;
|
||
options?.onProgress?.(total, total);
|
||
return { keywords, labels, scanned: total, total, complete: true };
|
||
}
|
||
|
||
/**
|
||
* Per-tab unread counts for message-list category tabs. One Email/query
|
||
* (limit 0, calculateTotal) per tab, batched into as few requests as the
|
||
* server's method-call ceiling allows. Each entry's `filter` is the tab's
|
||
* resolved FilterCondition/FilterOperator (null = no extra condition, i.e.
|
||
* all unread in the mailbox).
|
||
*/
|
||
async getCategoryUnreadCounts(
|
||
mailboxId: string,
|
||
tabs: Array<{ id: string; filter: Record<string, unknown> | null }>,
|
||
accountId?: string,
|
||
): Promise<Record<string, number>> {
|
||
if (tabs.length === 0) return {};
|
||
const targetAccountId = accountId || this.accountId;
|
||
const result: Record<string, number> = {};
|
||
|
||
for (const batch of batched(tabs, this.getMaxCallsInRequest())) {
|
||
try {
|
||
const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => {
|
||
const conditions: Record<string, unknown>[] = [
|
||
{ inMailbox: mailboxId },
|
||
{ notKeyword: "$seen" },
|
||
];
|
||
if (tab.filter) conditions.push(tab.filter);
|
||
return ["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter: { operator: "AND", conditions },
|
||
limit: 0,
|
||
calculateTotal: true,
|
||
}, `tab_${i}`];
|
||
});
|
||
|
||
const response = await this.request(methodCalls);
|
||
for (let i = 0; i < batch.length; i++) {
|
||
result[batch[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to get category tab counts:', error);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
|
||
try {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
const response = await this.request([
|
||
["Email/get", {
|
||
accountId: targetAccountId,
|
||
ids: [emailId],
|
||
properties: [
|
||
"id", "threadId", "mailboxIds", "keywords", "size",
|
||
"receivedAt", "sentAt", "from", "to", "cc", "bcc", "replyTo",
|
||
"subject", "preview", "textBody", "htmlBody", "bodyValues",
|
||
"hasAttachment", "attachments", "messageId", "inReplyTo",
|
||
"references", "headers", "bodyStructure", "blobId",
|
||
],
|
||
fetchTextBodyValues: true,
|
||
fetchHTMLBodyValues: true,
|
||
fetchAllBodyValues: true,
|
||
maxBodyValueBytes: 256000,
|
||
}, "0"],
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] !== "Email/get") {
|
||
return null;
|
||
}
|
||
|
||
const email = (response.methodResponses[0][1].list || [])[0];
|
||
if (!email) return null;
|
||
|
||
if (accountId && accountId !== this.accountId) {
|
||
namespaceMailboxIds([email], accountId);
|
||
}
|
||
|
||
if (email.headers) {
|
||
await this.parseEmailHeaders(email);
|
||
}
|
||
|
||
return email;
|
||
} catch (error) {
|
||
console.error('Failed to get email:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private async parseEmailHeaders(email: Email): Promise<void> {
|
||
const { parseAuthenticationResults, parseSpamScore, parseSpamLLM } = await import('@/lib/email-headers');
|
||
|
||
let headersRecord: Record<string, string | string[]>;
|
||
if (Array.isArray(email.headers)) {
|
||
headersRecord = {};
|
||
for (const header of email.headers as unknown as JMAPEmailHeader[]) {
|
||
if (!header?.name || !header?.value) continue;
|
||
const existing = headersRecord[header.name];
|
||
if (existing) {
|
||
headersRecord[header.name] = Array.isArray(existing)
|
||
? [...existing, header.value]
|
||
: [existing, header.value];
|
||
} else {
|
||
headersRecord[header.name] = header.value;
|
||
}
|
||
}
|
||
email.headers = headersRecord;
|
||
} else {
|
||
headersRecord = email.headers as Record<string, string | string[]>;
|
||
}
|
||
|
||
const authResultsHeader = headersRecord['Authentication-Results'];
|
||
if (authResultsHeader) {
|
||
// Multiple Authentication-Results headers (or multiple SPF identities in
|
||
// one header) must all be considered so the most severe result wins.
|
||
const value = Array.isArray(authResultsHeader) ? authResultsHeader.join('; ') : authResultsHeader;
|
||
email.authenticationResults = parseAuthenticationResults(value);
|
||
}
|
||
|
||
for (const headerName of ['X-Spam-Score', 'X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']) {
|
||
if (!headersRecord[headerName]) continue;
|
||
const value = Array.isArray(headersRecord[headerName]) ? headersRecord[headerName][0] : headersRecord[headerName];
|
||
const spamResult = parseSpamScore((value as string).trim());
|
||
if (spamResult) {
|
||
email.spamScore = spamResult.score;
|
||
email.spamStatus = spamResult.status;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const llmHeader = headersRecord['X-Spam-LLM'];
|
||
if (llmHeader) {
|
||
const value = Array.isArray(llmHeader) ? llmHeader[0] : llmHeader;
|
||
const llmResult = parseSpamLLM(value as string);
|
||
if (llmResult) {
|
||
email.spamLLM = llmResult;
|
||
}
|
||
}
|
||
}
|
||
|
||
async markAsRead(emailId: string, read: boolean = true, accountId?: string): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: {
|
||
[emailId]: {
|
||
"keywords/$seen": read ? true : null,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
|
||
if (emailIds.length === 0) return;
|
||
|
||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||
const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read ? true : null }]));
|
||
await this.request([
|
||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||
]);
|
||
}
|
||
}
|
||
|
||
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
"keywords/$flagged": starred ? true : null,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>, accountId?: string): Promise<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
keywords,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async setKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
[keywordPointer(keyword)]: true,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
[emailId]: {
|
||
[keywordPointer(keyword)]: null,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Apply the same keyword PatchObject fragment to many messages in one
|
||
* Email/set. `patch` keys are `keywords/<name>` pointers with true (add)
|
||
* or null (remove) values - the category-tab move primitive.
|
||
*/
|
||
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
|
||
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
|
||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||
const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
|
||
await this.request([
|
||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||
]);
|
||
}
|
||
}
|
||
|
||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<KeywordMigration> {
|
||
// Query all email IDs that have the old keyword
|
||
const seen = new Set<string>();
|
||
const allIds: string[] = [];
|
||
let position = 0;
|
||
const batchSize = 100;
|
||
// Positions only line up within one queryState (RFC 8620 section 5.5).
|
||
// Mail arriving mid-walk shifts every later page, so a changed state
|
||
// restarts the walk - bounded, since a busy mailbox could change forever.
|
||
let queryState: string | undefined;
|
||
let restarts = 0;
|
||
const maxRestarts = 3;
|
||
|
||
while (true) {
|
||
const response = await this.request([
|
||
["Email/query", {
|
||
accountId: this.accountId,
|
||
filter: { hasKeyword: oldKeyword },
|
||
limit: batchSize,
|
||
position,
|
||
}, "0"],
|
||
]);
|
||
|
||
const [queryMethod, queryResult] = response.methodResponses?.[0] || [];
|
||
if (queryMethod === "error") {
|
||
// Reading a refusal as "no messages carry the tag" would report a
|
||
// clean migration of mail that still has the old keyword.
|
||
throw new Error(queryResult?.description || "Failed to list emails with the keyword");
|
||
}
|
||
const state = queryResult?.queryState as string | undefined;
|
||
if (queryState === undefined) {
|
||
queryState = state;
|
||
} else if (state !== undefined && state !== queryState) {
|
||
if (restarts >= maxRestarts) {
|
||
// Carrying on would mix ids from states that no longer agree, and
|
||
// retagging a message the tag has since been taken off is worse than
|
||
// not retagging at all. Nothing has been written yet, so this is a
|
||
// clean failure the caller can simply repeat.
|
||
throw new Error("The mailbox kept changing while listing the messages to retag");
|
||
}
|
||
restarts++;
|
||
queryState = state;
|
||
seen.clear();
|
||
allIds.length = 0;
|
||
position = 0;
|
||
continue;
|
||
}
|
||
|
||
const ids: string[] = queryResult?.ids || [];
|
||
const fresh = ids.filter(id => !seen.has(id));
|
||
for (const id of fresh) seen.add(id);
|
||
allIds.push(...fresh);
|
||
|
||
// A server may return fewer ids than the limit asked for (RFC 8620
|
||
// section 5.5 lets it clamp), so a short page does not end the walk.
|
||
// A page carrying nothing new under an unchanged state does: the walk
|
||
// is not advancing, and continuing would loop for as long as the server
|
||
// keeps answering. Position counts the server's own page, not the ids
|
||
// this walk had not seen before.
|
||
if (fresh.length === 0) break;
|
||
position += ids.length;
|
||
}
|
||
|
||
if (allIds.length === 0) return { migrated: 0, refused: 0 };
|
||
|
||
// Batch update: remove old keyword, add new keyword using per-property patches
|
||
let migrated = 0;
|
||
let refusals = 0;
|
||
// A whole batch the server refused to take: it will not take the next one
|
||
// either. A single message it refused: the rest of the mail is unaffected,
|
||
// and only the reason is worth keeping.
|
||
let stopped: Error | null = null;
|
||
let refusal: Error | null = null;
|
||
for (const batch of batched(allIds, this.getMaxObjectsInSet())) {
|
||
if (stopped) {
|
||
refusals += batch.length;
|
||
continue;
|
||
}
|
||
|
||
const update: Record<string, Record<string, boolean | null>> = {};
|
||
for (const id of batch) {
|
||
update[id] = {
|
||
[keywordPointer(oldKeyword)]: null,
|
||
[keywordPointer(newKeyword)]: true,
|
||
};
|
||
}
|
||
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: this.accountId,
|
||
update,
|
||
}, "0"],
|
||
]);
|
||
|
||
const [setMethod, setResult] = response.methodResponses?.[0] || [];
|
||
if (setMethod === "error") {
|
||
stopped = new Error(setResult?.description || "Failed to migrate keyword");
|
||
refusals += batch.length;
|
||
continue;
|
||
}
|
||
// Email/set applies per message, so a refusal is never all-or-nothing:
|
||
// throwing here would abandon messages this very call already migrated.
|
||
// Report instead - a message deleted since the query (notFound) is
|
||
// nothing to report, any other refusal leaves the old keyword in place
|
||
// on mail the caller would otherwise treat as migrated.
|
||
const notUpdated = (setResult?.notUpdated || {}) as Record<string, { type?: string; description?: string }>;
|
||
const failed = Object.entries(notUpdated);
|
||
const refused = failed.filter(([, error]) => error?.type !== 'notFound');
|
||
if (refused.length > 0) {
|
||
debug.warn('keywords', 'Keyword migration refused for', refused.length, 'message(s):', refused[0][1]);
|
||
refusal = refusal || new Error(refused[0][1]?.description || refused[0][1]?.type || "Failed to migrate keyword");
|
||
}
|
||
refusals += refused.length;
|
||
migrated += batch.length - failed.length;
|
||
}
|
||
|
||
// Nothing moved: the caller must not rename the tag over messages that all
|
||
// kept the old keyword. Something moved: that is a partial migration the
|
||
// caller reports, not an error - the messages that did move are migrated
|
||
// and renaming the definition is what keeps them named.
|
||
if (migrated === 0 && (stopped || refusal)) throw (stopped || refusal) as Error;
|
||
|
||
return { migrated, refused: refusals };
|
||
}
|
||
|
||
async deleteEmail(emailId: string, accountId?: string): Promise<void> {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
destroy: [emailId],
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
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;
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: { [emailId]: patch },
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
|
||
if (emailIds.length === 0) return;
|
||
|
||
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: accountId || this.accountId,
|
||
destroy: batch,
|
||
}, "0"],
|
||
]);
|
||
}
|
||
}
|
||
|
||
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<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()]));
|
||
await this.request([
|
||
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||
]);
|
||
}
|
||
}
|
||
|
||
async batchArchiveEmails(
|
||
emails: Array<{ id: string; receivedAt: string }>,
|
||
archiveMailboxId: string,
|
||
mode: 'single' | 'year' | 'month',
|
||
existingMailboxes: Mailbox[],
|
||
accountId?: string,
|
||
): Promise<void> {
|
||
if (emails.length === 0) return;
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
if (mode === 'single') {
|
||
await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId, targetAccountId);
|
||
return;
|
||
}
|
||
|
||
type Dest = { year: string; month?: string };
|
||
const destFor = new Map<string, Dest>();
|
||
for (const e of emails) {
|
||
const d = new Date(e.receivedAt);
|
||
const year = d.getFullYear().toString();
|
||
const month = (d.getMonth() + 1).toString().padStart(2, '0');
|
||
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
|
||
}
|
||
|
||
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
|
||
const yearIdFor = new Map<string, string>();
|
||
const monthIdFor = new Map<string, string>();
|
||
const createEntries: Record<string, Record<string, unknown>> = {};
|
||
|
||
const findExisting = (name: string, parentId: string) =>
|
||
existingMailboxes.find(m =>
|
||
m.accountId === targetAccountId &&
|
||
m.name === name &&
|
||
(m.parentId === parentId || m.parentId === (parentId.startsWith('#') ? undefined : parentId)),
|
||
);
|
||
|
||
for (const dest of destFor.values()) {
|
||
if (!yearIdFor.has(dest.year)) {
|
||
const existing = findExisting(dest.year, archiveMailboxId);
|
||
if (existing) {
|
||
yearIdFor.set(dest.year, existing.originalId || existing.id);
|
||
} else {
|
||
const cid = `year-${dest.year}`;
|
||
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
|
||
yearIdFor.set(dest.year, `#${cid}`);
|
||
}
|
||
}
|
||
|
||
if (mode === 'month' && dest.month) {
|
||
const monthKey = `${dest.year}/${dest.month}`;
|
||
if (!monthIdFor.has(monthKey)) {
|
||
const yearRef = yearIdFor.get(dest.year)!;
|
||
// Only look up existing month folders under real (non-creation-ref) year ids.
|
||
const existingMonth = yearRef.startsWith('#')
|
||
? undefined
|
||
: findExisting(dest.month, yearRef);
|
||
if (existingMonth) {
|
||
monthIdFor.set(monthKey, existingMonth.originalId || existingMonth.id);
|
||
} else {
|
||
const cid = `month-${dest.year}-${dest.month}`;
|
||
createEntries[cid] = { name: dest.month, parentId: yearRef };
|
||
monthIdFor.set(monthKey, `#${cid}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
|
||
for (const [emailId, dest] of destFor.entries()) {
|
||
const destId = mode === 'month' && dest.month
|
||
? monthIdFor.get(`${dest.year}/${dest.month}`)!
|
||
: yearIdFor.get(dest.year)!;
|
||
updates[emailId] = { mailboxIds: { [destId]: true } };
|
||
}
|
||
|
||
// Creation ids are scoped to the request that introduced them (RFC 8620
|
||
// §3.3), so "#<cid>" only resolves in the request carrying the Mailbox/set:
|
||
// the folders are created alongside the first batch of messages, and the
|
||
// ids they were assigned are substituted into every later batch.
|
||
const updateBatches = batched(Object.entries(updates), this.getMaxObjectsInSet());
|
||
const hasCreates = Object.keys(createEntries).length > 0;
|
||
let createdIdFor: Record<string, string> = {};
|
||
|
||
for (let i = 0; i < updateBatches.length; i++) {
|
||
const batch: Array<[string, { mailboxIds: Record<string, true> }]> = i === 0
|
||
? updateBatches[i]
|
||
: updateBatches[i].map(([emailId, patch]) => {
|
||
const [destId] = Object.keys(patch.mailboxIds);
|
||
const resolved = createdIdFor[destId];
|
||
return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record<string, true> } : patch];
|
||
});
|
||
|
||
const methodCalls: JMAPMethodCall[] = [];
|
||
const withCreates = hasCreates && i === 0;
|
||
if (withCreates) {
|
||
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
|
||
}
|
||
methodCalls.push(['Email/set', { accountId: targetAccountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]);
|
||
|
||
const response = await this.request(methodCalls);
|
||
|
||
if (withCreates) {
|
||
const mailboxResult = response.methodResponses?.[0]?.[1];
|
||
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
|
||
const failures = notCreated ? Object.entries(notCreated) : [];
|
||
if (failures.length > 0) {
|
||
const [cid, err] = failures[0];
|
||
const parts = [err.type || 'unknown'];
|
||
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
|
||
if (err.description) parts.push(err.description);
|
||
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
|
||
}
|
||
const created = (mailboxResult?.created || {}) as Record<string, { id?: string }>;
|
||
createdIdFor = Object.fromEntries(
|
||
Object.entries(created)
|
||
.filter(([, mailbox]) => !!mailbox?.id)
|
||
.map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]),
|
||
);
|
||
}
|
||
|
||
const emailIdx = withCreates ? 1 : 0;
|
||
const emailResult = response.methodResponses?.[emailIdx]?.[1];
|
||
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
|
||
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
|
||
if (emailFailures.length > 0) {
|
||
const [id, err] = emailFailures[0];
|
||
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
async moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: {
|
||
[emailId]: {
|
||
mailboxIds: { [toMailboxId]: true },
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[emailId]) {
|
||
throw new Error(`Failed to move email: ${result.notUpdated[emailId].type || 'unknown error'}`);
|
||
}
|
||
}
|
||
|
||
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const batchSize = Math.min(500, this.getMaxObjectsInSet());
|
||
let totalDestroyed = 0;
|
||
|
||
// Destroy in batches until the mailbox is empty. Never gate the loop on
|
||
// Email/query's `total`: it is only guaranteed when `calculateTotal` is
|
||
// requested, and Stalwart omits it otherwise, which used to stop the loop
|
||
// after the first batch and leave folders with >500 emails mostly intact.
|
||
while (true) {
|
||
const response = await this.request([
|
||
["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter: { inMailbox: mailboxId },
|
||
limit: batchSize,
|
||
}, "0"],
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
"#destroy": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||
}, "1"],
|
||
]);
|
||
|
||
const queryResult = response.methodResponses?.[0]?.[1];
|
||
const setResult = response.methodResponses?.[1]?.[1];
|
||
const found: string[] = queryResult?.ids || [];
|
||
const destroyed = setResult?.destroyed?.length || 0;
|
||
totalDestroyed += destroyed;
|
||
|
||
// Nothing left, or the server refused everything in this batch (missing
|
||
// permission, immutable mail) — stop instead of looping forever on the
|
||
// same ids.
|
||
if (found.length === 0 || destroyed === 0) break;
|
||
// A short page means we just handled the tail of the mailbox.
|
||
if (found.length < batchSize) break;
|
||
}
|
||
|
||
return totalDestroyed;
|
||
}
|
||
|
||
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const pageSize = Math.min(500, this.getMaxObjectsInSet());
|
||
let totalMarked = 0;
|
||
let hasMore = true;
|
||
|
||
while (hasMore) {
|
||
const queryResponse = await this.request([
|
||
["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter: {
|
||
operator: "AND",
|
||
conditions: [
|
||
{ inMailbox: mailboxId },
|
||
{ notKeyword: "$seen" },
|
||
],
|
||
},
|
||
limit: pageSize,
|
||
}, "0"],
|
||
]);
|
||
|
||
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||
if (ids.length === 0) break;
|
||
|
||
const updates = Object.fromEntries(
|
||
ids.map((id) => [id, { "keywords/$seen": true }])
|
||
);
|
||
|
||
await this.request([
|
||
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
|
||
]);
|
||
|
||
totalMarked += ids.length;
|
||
hasMore = ids.length === pageSize;
|
||
}
|
||
|
||
return totalMarked;
|
||
}
|
||
|
||
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const excludeSet = new Set(excludeMailboxIds);
|
||
const pageSize = Math.min(500, this.getMaxObjectsInGet(), this.getMaxObjectsInSet());
|
||
let totalMarked = 0;
|
||
let hasMore = true;
|
||
let position = 0;
|
||
|
||
while (hasMore) {
|
||
const response = await this.request([
|
||
["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter: { notKeyword: "$seen" },
|
||
limit: pageSize,
|
||
position,
|
||
}, "0"],
|
||
["Email/get", {
|
||
accountId: targetAccountId,
|
||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||
properties: ["id", "mailboxIds"],
|
||
}, "1"],
|
||
]);
|
||
|
||
const queryResult = response.methodResponses?.[0]?.[1];
|
||
const getResult = response.methodResponses?.[1]?.[1];
|
||
const ids: string[] = queryResult?.ids || [];
|
||
const emails: Array<{ id: string; mailboxIds?: Record<string, boolean> }> = getResult?.list || [];
|
||
|
||
if (ids.length === 0) break;
|
||
|
||
const targetIds = excludeSet.size === 0
|
||
? ids
|
||
: emails
|
||
.filter(e => {
|
||
const mbIds = e.mailboxIds ? Object.keys(e.mailboxIds) : [];
|
||
return mbIds.some(id => !excludeSet.has(id));
|
||
})
|
||
.map(e => e.id);
|
||
|
||
if (targetIds.length > 0) {
|
||
const updates = Object.fromEntries(
|
||
targetIds.map((id) => [id, { "keywords/$seen": true }])
|
||
);
|
||
await this.request([
|
||
["Email/set", { accountId: targetAccountId, update: updates }, "0"],
|
||
]);
|
||
totalMarked += targetIds.length;
|
||
}
|
||
|
||
hasMore = ids.length === pageSize;
|
||
position += ids.length;
|
||
}
|
||
|
||
return totalMarked;
|
||
}
|
||
|
||
async markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
const mailboxes = await this.getMailboxes(accountId);
|
||
const junkMailbox = mailboxes.find(m => {
|
||
if (accountId) {
|
||
return m.role === 'junk' && m.accountId === accountId;
|
||
}
|
||
return m.role === 'junk' && !m.isShared;
|
||
});
|
||
|
||
if (!junkMailbox) {
|
||
throw new Error('Junk mailbox not found');
|
||
}
|
||
|
||
const mailboxId = accountId && junkMailbox.originalId
|
||
? junkMailbox.originalId
|
||
: junkMailbox.id;
|
||
|
||
// Move + flag in one patch so other JMAP clients (and the server's spam
|
||
// classifier) see the message as junk, not just filed in Junk. (#850)
|
||
const patch: Record<string, unknown> = {
|
||
mailboxIds: { [mailboxId]: true },
|
||
"keywords/$junk": true,
|
||
"keywords/$notjunk": null,
|
||
};
|
||
if (markAsRead) patch["keywords/$seen"] = true;
|
||
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: { [emailId]: patch },
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
await this.request([
|
||
["Email/set", {
|
||
accountId: targetAccountId,
|
||
update: {
|
||
[emailId]: {
|
||
mailboxIds: { [originalMailboxId]: true },
|
||
// Moving alone leaves $junk set, so the message shows up in the
|
||
// Inbox while still flagged as junk for every other client. (#850)
|
||
"keywords/$junk": null,
|
||
"keywords/$notjunk": true,
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
}
|
||
|
||
async createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const createId = `new-${Date.now()}`;
|
||
const createData: Record<string, unknown> = { name };
|
||
if (parentId) {
|
||
createData.parentId = parentId;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["Mailbox/set", {
|
||
accountId: targetAccountId,
|
||
create: { [createId]: createData },
|
||
}, "0"],
|
||
]);
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notCreated?.[createId]) {
|
||
const err = result.notCreated[createId];
|
||
const details = [err.type || 'unknown error'];
|
||
if (Array.isArray(err.properties) && err.properties.length > 0) {
|
||
details.push(`properties=[${err.properties.join(', ')}]`);
|
||
}
|
||
if (err.description) details.push(err.description);
|
||
throw new Error(`Failed to create mailbox: ${details.join(' – ')}`);
|
||
}
|
||
|
||
const created = result?.created?.[createId];
|
||
if (!created?.id) {
|
||
throw new Error('Failed to create mailbox: no ID returned');
|
||
}
|
||
|
||
return {
|
||
id: created.id,
|
||
name,
|
||
parentId,
|
||
sortOrder: 0,
|
||
totalEmails: 0,
|
||
unreadEmails: 0,
|
||
totalThreads: 0,
|
||
unreadThreads: 0,
|
||
myRights: DEFAULT_MAILBOX_RIGHTS,
|
||
isSubscribed: true,
|
||
accountId: targetAccountId,
|
||
accountName: this.accounts[targetAccountId]?.name || (targetAccountId === this.accountId ? this.username : targetAccountId),
|
||
isShared: targetAccountId !== this.accountId,
|
||
};
|
||
}
|
||
|
||
async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const response = await this.request([
|
||
["Mailbox/set", {
|
||
accountId: targetAccountId,
|
||
update: { [mailboxId]: changes },
|
||
}, "0"],
|
||
]);
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[mailboxId]) {
|
||
throw new Error(`Failed to update mailbox: ${result.notUpdated[mailboxId].type || 'unknown error'}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Destroys a folder. By default the server refuses a non-empty folder
|
||
* (`mailboxHasEmail`); with `removeEmails` the messages that are only in
|
||
* this folder go with it (`onDestroyRemoveEmails`, RFC 8621 §2.5).
|
||
*/
|
||
async deleteMailbox(mailboxId: string, accountId?: string, options?: { removeEmails?: boolean }): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const response = await this.request([
|
||
["Mailbox/set", {
|
||
accountId: targetAccountId,
|
||
destroy: [mailboxId],
|
||
...(options?.removeEmails ? { onDestroyRemoveEmails: true } : {}),
|
||
}, "0"],
|
||
]);
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notDestroyed?.[mailboxId]) {
|
||
const err = result.notDestroyed[mailboxId];
|
||
const error = new Error(err.description || `Failed to delete mailbox: ${err.type || 'unknown error'}`);
|
||
(error as Error & { jmapType?: string }).jmapType = err.type;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||
try {
|
||
const targetAccountId = accountId || this.accountId;
|
||
|
||
// Use the JMAP "text" filter which searches across from, to, cc, bcc,
|
||
// subject, and body. Stalwart's FTS engine supports wildcard prefix
|
||
// matching (e.g. "pri*" matches "prime", "primary", "private", etc.)
|
||
const wildcardQuery = toWildcardQuery(query);
|
||
const textFilter: Record<string, unknown> = { text: wildcardQuery };
|
||
|
||
let filter: Record<string, unknown>;
|
||
if (mailboxId) {
|
||
filter = {
|
||
operator: "AND",
|
||
conditions: [
|
||
{ inMailbox: mailboxId },
|
||
textFilter,
|
||
],
|
||
};
|
||
} else {
|
||
filter = textFilter;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["Email/query", {
|
||
accountId: targetAccountId,
|
||
filter,
|
||
sort: [{ property: "receivedAt", isAscending: false }],
|
||
limit,
|
||
position,
|
||
calculateTotal: true,
|
||
}, "0"],
|
||
["Email/get", {
|
||
accountId: targetAccountId,
|
||
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
|
||
properties: [...EMAIL_LIST_PROPERTIES],
|
||
}, "1"],
|
||
this.searchSnippetCall(targetAccountId, filter),
|
||
]);
|
||
|
||
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) {
|
||
console.error('Search failed:', error);
|
||
return { emails: [], hasMore: false, total: 0 };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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),
|
||
]);
|
||
|
||
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) {
|
||
if (accountId && accountId !== this.accountId) {
|
||
namespaceMailboxIds(emails, accountId);
|
||
}
|
||
|
||
return emails.sort((a: Email, b: Email) =>
|
||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
||
);
|
||
}
|
||
|
||
return [];
|
||
} catch (error) {
|
||
console.error('Failed to get thread emails:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async getIdentities(): Promise<Identity[]> {
|
||
try {
|
||
const response = await this.request([
|
||
["Identity/get", {
|
||
accountId: this.accountId,
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Identity/get") {
|
||
const list = (response.methodResponses[0][1].list || []) as Identity[];
|
||
return list.map((id) => ({ ...id, name: sanitizeIdentityDisplayName(id.name) }));
|
||
}
|
||
|
||
return [];
|
||
} catch (error) {
|
||
console.error('Failed to get identities:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async createIdentity(
|
||
name: string,
|
||
email: string,
|
||
replyTo?: EmailAddress[] | null,
|
||
bcc?: EmailAddress[] | null,
|
||
textSignature?: string | null,
|
||
htmlSignature?: string | null
|
||
): Promise<Identity> {
|
||
const response = await this.request([
|
||
["Identity/set", {
|
||
accountId: this.accountId,
|
||
create: {
|
||
"new-identity": {
|
||
name,
|
||
email,
|
||
replyTo,
|
||
bcc,
|
||
textSignature,
|
||
htmlSignature,
|
||
}
|
||
}
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notCreated?.["new-identity"]) {
|
||
const error = result.notCreated["new-identity"];
|
||
if (error.type === "forbidden") {
|
||
throw new Error("You are not authorized to send from this email address");
|
||
}
|
||
throw new Error(error.description || "Failed to create identity");
|
||
}
|
||
|
||
const createdId = result.created?.["new-identity"]?.id;
|
||
if (createdId) {
|
||
const identities = await this.getIdentities();
|
||
const identity = identities.find(i => i.id === createdId);
|
||
if (identity) return identity;
|
||
}
|
||
}
|
||
|
||
throw new Error("Failed to create identity: Server response was unexpected. Check server logs.");
|
||
}
|
||
|
||
async updateIdentity(
|
||
identityId: string,
|
||
updates: {
|
||
name?: string | null;
|
||
replyTo?: EmailAddress[] | null;
|
||
bcc?: EmailAddress[] | null;
|
||
textSignature?: string | null;
|
||
htmlSignature?: string | null;
|
||
}
|
||
): Promise<void> {
|
||
const response = await this.request([
|
||
["Identity/set", {
|
||
accountId: this.accountId,
|
||
update: {
|
||
[identityId]: updates
|
||
}
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notUpdated?.[identityId]) {
|
||
const error = result.notUpdated[identityId];
|
||
if (error.type === "notFound") {
|
||
throw new Error("Identity not found (may have been deleted)");
|
||
}
|
||
if (error.type === "forbidden") {
|
||
throw new Error("You are not authorized to modify this identity");
|
||
}
|
||
throw new Error(error.description || "Failed to update identity");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to update identity: Server response was unexpected. Check server logs.");
|
||
}
|
||
|
||
async deleteIdentity(identityId: string): Promise<void> {
|
||
const response = await this.request([
|
||
["Identity/set", {
|
||
accountId: this.accountId,
|
||
destroy: [identityId]
|
||
}, "0"]
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Identity/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notDestroyed?.[identityId]) {
|
||
const error = result.notDestroyed[identityId];
|
||
if (error.type === "forbidden") {
|
||
throw new Error("This identity cannot be deleted");
|
||
}
|
||
if (error.type === "notFound") {
|
||
throw new Error("Identity not found (may already be deleted)");
|
||
}
|
||
throw new Error(error.description || "Failed to delete identity");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
|
||
}
|
||
|
||
private vacationUsing(): string[] {
|
||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"];
|
||
}
|
||
|
||
async getVacationResponse(accountId?: string): Promise<VacationResponse> {
|
||
const response = await this.request([
|
||
["VacationResponse/get", {
|
||
accountId: accountId || this.accountId,
|
||
ids: ["singleton"],
|
||
}, "0"]
|
||
], this.vacationUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "VacationResponse/get") {
|
||
const list = response.methodResponses[0][1].list || [];
|
||
if (list.length > 0) {
|
||
return list[0] as VacationResponse;
|
||
}
|
||
return {
|
||
id: "singleton",
|
||
isEnabled: false,
|
||
fromDate: null,
|
||
toDate: null,
|
||
subject: "",
|
||
textBody: "",
|
||
htmlBody: null,
|
||
};
|
||
}
|
||
|
||
throw new Error("Failed to fetch vacation response: unexpected server response");
|
||
}
|
||
|
||
async setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void> {
|
||
const response = await this.request([
|
||
["VacationResponse/set", {
|
||
accountId: accountId || this.accountId,
|
||
update: {
|
||
"singleton": updates,
|
||
},
|
||
}, "0"]
|
||
], this.vacationUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "VacationResponse/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notUpdated?.["singleton"]) {
|
||
const error = result.notUpdated["singleton"];
|
||
throw new Error(error.description || "Failed to update vacation response");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to update vacation response");
|
||
}
|
||
|
||
async createDraft(
|
||
to: string[],
|
||
subject: string,
|
||
body: string,
|
||
cc?: string[],
|
||
bcc?: string[],
|
||
identityId?: string,
|
||
fromEmail?: string,
|
||
draftId?: string,
|
||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||
fromName?: string,
|
||
htmlBody?: string
|
||
): Promise<string> {
|
||
const mailboxes = await this.getMailboxes();
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
if (!draftsMailbox) {
|
||
throw new Error('No drafts mailbox found');
|
||
}
|
||
|
||
const emailId = `draft-${Date.now()}`;
|
||
|
||
interface EmailDraft {
|
||
from: { name?: string; email: string }[];
|
||
to: { name?: string; email: string }[];
|
||
cc?: { name?: string; email: string }[];
|
||
bcc?: { name?: string; email: string }[];
|
||
subject: string;
|
||
keywords: Record<string, boolean>;
|
||
mailboxIds: Record<string, boolean>;
|
||
bodyValues: Record<string, { value: string }>;
|
||
textBody: { partId: string; type?: string }[];
|
||
htmlBody?: { partId: string; type: string }[];
|
||
attachments?: { blobId: string; type: string; name: string; disposition: string; cid?: string }[];
|
||
}
|
||
|
||
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
|
||
const emailData: EmailDraft = {
|
||
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||
// "Name <addr>" must be split into the two JMAP fields: storing the whole
|
||
// mailbox as the address makes the server treat the display name as part
|
||
// of the addr-spec, and the draft goes out to `Name<addr` (#672).
|
||
to: to.map(parseRecipientString),
|
||
cc: cc?.length ? cc.map(parseRecipientString) : undefined,
|
||
bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined,
|
||
subject,
|
||
keywords: { "$seen": true, "$draft": true },
|
||
mailboxIds: { [draftsMailbox.id]: true },
|
||
bodyValues: htmlBody
|
||
? { "text": { value: body }, "html": { value: htmlBody } }
|
||
: { "1": { value: body } },
|
||
textBody: htmlBody
|
||
? [{ partId: "text", type: "text/plain" }]
|
||
: [{ partId: "1" }],
|
||
...(htmlBody ? { htmlBody: [{ partId: "html", type: "text/html" }] } : {}),
|
||
};
|
||
|
||
if (attachments?.length) {
|
||
emailData.attachments = attachments.map(att => ({
|
||
blobId: att.blobId,
|
||
type: att.type,
|
||
name: att.name,
|
||
disposition: att.disposition ?? "attachment",
|
||
...(att.cid ? { cid: att.cid } : {}),
|
||
}));
|
||
}
|
||
|
||
// Destroy the previous draft version only AFTER the replacement was
|
||
// created (#849). A combined `Email/set { create, destroy }` processes
|
||
// the two independently: when the create references part blobs of the
|
||
// previous version (re-opened draft with attachments) and fails with
|
||
// blobNotFound, the destroy still went through - deleting the last good
|
||
// copy of the draft. Worst case now is an orphaned old version.
|
||
const response = await this.request([
|
||
["Email/set", {
|
||
accountId: this.accountId,
|
||
create: { [emailId]: emailData },
|
||
}, "0"],
|
||
]);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Email/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notCreated) {
|
||
const errors = result.notCreated;
|
||
const firstError = Object.values(errors)[0] as { description?: string; type?: string };
|
||
console.error('Draft save error:', firstError);
|
||
throw new Error(firstError?.description || firstError?.type || 'Failed to save draft');
|
||
}
|
||
|
||
if (result.created?.[emailId]) {
|
||
const newDraftId = result.created[emailId].id;
|
||
|
||
if (draftId) {
|
||
try {
|
||
const destroyResponse = await this.request([
|
||
["Email/set", { accountId: this.accountId, destroy: [draftId] }, "0"],
|
||
]);
|
||
const destroyResult = destroyResponse.methodResponses?.[0]?.[1];
|
||
if (destroyResult?.notDestroyed) {
|
||
console.warn('Failed to destroy old draft:', destroyResult.notDestroyed);
|
||
}
|
||
} catch (err) {
|
||
console.warn('Failed to destroy old draft:', err);
|
||
}
|
||
}
|
||
|
||
return newDraftId;
|
||
}
|
||
}
|
||
|
||
console.error('Unexpected draft save response:', response);
|
||
throw new Error('Failed to save draft');
|
||
}
|
||
|
||
async sendEmail(
|
||
to: string[],
|
||
subject: string,
|
||
body: string,
|
||
cc?: string[],
|
||
bcc?: string[],
|
||
identityId?: string,
|
||
fromEmail?: string,
|
||
draftId?: string,
|
||
fromName?: string,
|
||
htmlBody?: string,
|
||
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
||
inReplyTo?: string[],
|
||
references?: string[],
|
||
delayedUntil?: string,
|
||
envelopeMailFrom?: string,
|
||
options?: SubmissionSendOptions
|
||
): Promise<SendEmailResult> {
|
||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||
const emailId = `send-${Date.now()}`;
|
||
const targetAccountId = (fromEmail && Object.keys(this.accounts).find(id =>
|
||
this.accounts[id]?.name?.toLowerCase() === fromEmail.toLowerCase()
|
||
)) || this.accountId;
|
||
const mboxResp = await this.request([
|
||
["Mailbox/get", { accountId: targetAccountId }, "0"]
|
||
]);
|
||
const mailboxes = (mboxResp.methodResponses?.[0]?.[1]?.list || []) as Mailbox[];
|
||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||
if (!sentMailbox) {
|
||
throw new Error('No sent mailbox found');
|
||
}
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
if (!draftsMailbox) {
|
||
throw new Error('No drafts mailbox found');
|
||
}
|
||
|
||
let finalIdentityId = identityId;
|
||
let identityReplyTo: EmailAddress[] | undefined;
|
||
{
|
||
const identityResponse = await this.request([
|
||
["Identity/get", { accountId: targetAccountId }, "0"]
|
||
]);
|
||
|
||
if (!finalIdentityId) {
|
||
finalIdentityId = targetAccountId;
|
||
}
|
||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||
const identities = (identityResponse.methodResponses[0][1].list || []) as Identity[];
|
||
if (identities.length > 0) {
|
||
let matchingIdentity = identityId
|
||
? identities.find((id) => id.id === identityId)
|
||
: undefined;
|
||
if (!matchingIdentity) {
|
||
const target = fromEmail || this.username;
|
||
matchingIdentity = identities.find((id) => id.email === target)
|
||
|| (!target.includes('@') ? identities.find((id) => id.email.split('@')[0] === target) : undefined);
|
||
}
|
||
finalIdentityId = matchingIdentity?.id || identities[0].id;
|
||
identityReplyTo = matchingIdentity?.replyTo || identities[0].replyTo;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Per RFC 8621 §4.1.2.3 inReplyTo/references are arrays of bare msg-ids
|
||
// (no angle brackets). Stalwart may return them either way, so normalize.
|
||
const normalizedInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean);
|
||
const normalizedReferences = references?.map(stripMessageIdBrackets).filter(Boolean);
|
||
|
||
const sanitizedFromName = sanitizeIdentityDisplayName(fromName);
|
||
// Always create a new email with the final body content
|
||
const emailCreate: Record<string, unknown> = {
|
||
from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }],
|
||
replyTo: identityReplyTo?.length ? identityReplyTo : undefined,
|
||
to: to.map(parseRecipientString),
|
||
// RFC 5322 §3.6.3: To/Cc carry an address-list (non-empty). Sending
|
||
// cc:[] makes the server emit a literal `Cc:` header with no addresses,
|
||
// which is malformed and a spam signal. Omit the field when empty.
|
||
cc: cc?.length ? cc.map(parseRecipientString) : undefined,
|
||
bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined,
|
||
subject,
|
||
messageId: [generateMessageId(fromEmail || this.username)],
|
||
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
|
||
references: normalizedReferences?.length ? normalizedReferences : undefined,
|
||
keywords: { "$seen": true, "$draft": true },
|
||
mailboxIds: { [draftsMailbox.id]: true },
|
||
};
|
||
|
||
if (options?.requestReadReceipt) {
|
||
// RFC 8098: ask the recipient's client to return a Message Disposition
|
||
// Notification to our address. JMAP lets us set the raw header on create
|
||
// via the "header:<Name>:asText" property form.
|
||
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
|
||
}
|
||
|
||
if (htmlBody) {
|
||
// Send as multipart/alternative with both text and HTML
|
||
emailCreate.bodyValues = {
|
||
"text": { value: body },
|
||
"html": { value: htmlBody },
|
||
};
|
||
emailCreate.textBody = [{ partId: "text", type: "text/plain" }];
|
||
emailCreate.htmlBody = [{ partId: "html", type: "text/html" }];
|
||
} else {
|
||
emailCreate.bodyValues = { "1": { value: body } };
|
||
emailCreate.textBody = [{ partId: "1", type: "text/plain" }];
|
||
}
|
||
|
||
if (attachments?.length) {
|
||
emailCreate.attachments = attachments.map(att => ({
|
||
blobId: att.blobId,
|
||
type: att.type,
|
||
name: att.name,
|
||
disposition: att.disposition ?? "attachment",
|
||
...(att.cid ? { cid: att.cid } : {}),
|
||
}));
|
||
}
|
||
|
||
const methodCalls: JMAPMethodCall[] = [];
|
||
|
||
// Use onSuccessUpdateEmail to move from Drafts to Sent after submission.
|
||
// This ensures SMTP send happens before the email lands in Sent, avoiding
|
||
// issues with servers that encrypt on append (e.g. Stalwart). See #188.
|
||
const onSuccessUpdateEmail = {
|
||
"#1": {
|
||
...mailboxIdsReplacement(sentMailbox.id),
|
||
"keywords/$draft": null,
|
||
},
|
||
};
|
||
|
||
// When an explicit envelope MAIL FROM is provided (header From ≠ envelope,
|
||
// e.g. sending from a domain-catch-all alias without a dedicated Identity),
|
||
// set the EmailSubmission envelope explicitly. JMAP §7.3: when `envelope`
|
||
// is omitted the server derives mailFrom from the Identity.
|
||
const buildSubmissionCreate = (submissionId: string): Record<string, unknown> => {
|
||
const create: Record<string, unknown> = { emailId: `#${emailId}`, identityId: finalIdentityId };
|
||
const parameters = submissionEnvelopeParameters(options, holdForSeconds);
|
||
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
|
||
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
|
||
if (hasMailFromParams || hasRcptToParams || envelopeMailFrom) {
|
||
const envelopeRecipients = normalizeEnvelopeRecipients([...to, ...(cc || []), ...(bcc || [])]);
|
||
create.envelope = {
|
||
mailFrom: {
|
||
email: parseRecipientString(envelopeMailFrom || fromEmail || this.username).email,
|
||
...(hasMailFromParams ? { parameters: parameters.mailFrom } : {}),
|
||
},
|
||
rcptTo: hasRcptToParams
|
||
? envelopeRecipients.map((r) => ({ ...r, parameters: parameters.rcptTo }))
|
||
: envelopeRecipients,
|
||
};
|
||
}
|
||
return { [submissionId]: create };
|
||
};
|
||
|
||
// The old draft is destroyed in a separate request only after the
|
||
// submission succeeded (see below, #849). Destroying it up front in the
|
||
// same request meant a failed create/submission - e.g. the outgoing email
|
||
// referencing part blobs of that very draft, which die with it - still
|
||
// deleted the user's only copy of the message.
|
||
methodCalls.push(["Email/set", {
|
||
accountId: targetAccountId,
|
||
create: { [emailId]: emailCreate },
|
||
}, "0"]);
|
||
methodCalls.push(["EmailSubmission/set", {
|
||
accountId: this.getSubmissionAccountId(targetAccountId),
|
||
create: buildSubmissionCreate("1"),
|
||
onSuccessUpdateEmail,
|
||
}, "1"]);
|
||
|
||
const response = await this.request(methodCalls);
|
||
|
||
let createdEmailId: string | undefined;
|
||
let emailSubmissionId: string | undefined;
|
||
let serverSendAt: string | undefined;
|
||
let filingError: string | undefined;
|
||
|
||
if (response.methodResponses) {
|
||
for (const [methodName, result] of response.methodResponses) {
|
||
if (methodName.endsWith('/error')) {
|
||
console.error('[sendEmail] JMAP method error:', methodName, result);
|
||
throw new Error(result.description || `Failed to send email: ${result.type}`);
|
||
}
|
||
|
||
if (result.notCreated) {
|
||
// Include method name + full error object so it's clear whether the
|
||
// failure came from Email/set (draft create) or EmailSubmission/set
|
||
// (actual send) and which JMAP error type/properties were returned.
|
||
// Without this the user sees a generic "Failed to send" toast and
|
||
// the draft sits in Drafts with no indication of why (#303).
|
||
const errors = result.notCreated as Record<string, {
|
||
type?: string;
|
||
description?: string;
|
||
properties?: string[];
|
||
}>;
|
||
const firstError = Object.values(errors)[0];
|
||
console.error(
|
||
`[sendEmail] ${methodName} notCreated:`,
|
||
JSON.stringify(errors, null, 2),
|
||
);
|
||
const propsHint = firstError?.properties?.length
|
||
? ` (properties: ${firstError.properties.join(', ')})`
|
||
: '';
|
||
const typeHint = firstError?.type ? ` [${firstError.type}]` : '';
|
||
throw new Error(
|
||
`${firstError?.description || firstError?.type || 'Failed to send email'}${typeHint}${propsHint}`,
|
||
);
|
||
}
|
||
|
||
// Post-submission filing problems (the implicit Email/set from
|
||
// onSuccessUpdateEmail, or destroying the old draft) must not fail
|
||
// the send - the message already left - but they must not stay
|
||
// silent either: a silently rejected filing/cleanup is exactly how
|
||
// "sent mail still sits in Drafts" reports look (#592, #588's
|
||
// sibling note in 4dc76bbb). Log the details and surface a warning
|
||
// to the caller.
|
||
if (result.notUpdated && Object.keys(result.notUpdated).length) {
|
||
console.error(`[sendEmail] ${methodName} notUpdated:`, JSON.stringify(result.notUpdated, null, 2));
|
||
const first = Object.values(result.notUpdated as Record<string, { type?: string; description?: string }>)[0];
|
||
filingError = filingError ?? (first?.description || first?.type || 'post-send filing failed');
|
||
}
|
||
if (result.notDestroyed && Object.keys(result.notDestroyed).length) {
|
||
console.error(`[sendEmail] ${methodName} notDestroyed (old draft):`, JSON.stringify(result.notDestroyed, null, 2));
|
||
const first = Object.values(result.notDestroyed as Record<string, { type?: string; description?: string }>)[0];
|
||
filingError = filingError ?? (first?.description || first?.type || 'old draft cleanup failed');
|
||
}
|
||
|
||
if (methodName === 'Email/set' && result.created?.[emailId]?.id) {
|
||
createdEmailId = result.created[emailId].id;
|
||
}
|
||
if (methodName === 'EmailSubmission/set' && result.created?.['1']?.id) {
|
||
emailSubmissionId = result.created['1'].id;
|
||
serverSendAt = result.created['1'].sendAt;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The message is out (or scheduled) - now it is safe to drop the old
|
||
// draft. A failure here leaves an orphan in Drafts, which is reported as
|
||
// a filing warning rather than a failed send (#849).
|
||
if (draftId && createdEmailId) {
|
||
try {
|
||
const destroyResponse = await this.request([
|
||
["Email/set", { accountId: this.accountId, destroy: [draftId] }, "0"],
|
||
]);
|
||
const destroyResult = destroyResponse.methodResponses?.[0]?.[1];
|
||
if (destroyResult?.notDestroyed && Object.keys(destroyResult.notDestroyed).length) {
|
||
console.error('[sendEmail] old draft cleanup notDestroyed:', JSON.stringify(destroyResult.notDestroyed, null, 2));
|
||
const first = Object.values(destroyResult.notDestroyed as Record<string, { type?: string; description?: string }>)[0];
|
||
filingError = filingError ?? (first?.description || first?.type || 'old draft cleanup failed');
|
||
}
|
||
} catch (err) {
|
||
console.error('[sendEmail] old draft cleanup failed:', err);
|
||
filingError = filingError ?? 'old draft cleanup failed';
|
||
}
|
||
}
|
||
|
||
// The account the submission was created in — the shared account when
|
||
// sending from a shared identity. Returned so undo/send-now can address it
|
||
// directly instead of searching for it. See #874.
|
||
const submissionAccountId = this.getSubmissionAccountId(targetAccountId);
|
||
|
||
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||
}
|
||
|
||
return delayedUntil
|
||
? { scheduled: true, emailId: createdEmailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, filingError }
|
||
: { scheduled: false, emailId: createdEmailId, emailSubmissionId, submissionAccountId, filingError };
|
||
}
|
||
|
||
/**
|
||
* Send an iMIP (RFC 6047) REPLY email to the organizer after an RSVP.
|
||
*
|
||
* Fallback only: when `sendSchedulingMessages` is passed to the
|
||
* CalendarEvent/set RSVP patch, the server sends the iTIP REPLY itself -
|
||
* calling this in addition produces duplicate reply emails.
|
||
*/
|
||
async sendImipReply(opts: {
|
||
organizerEmail: string;
|
||
organizerName?: string;
|
||
attendeeEmail: string;
|
||
attendeeName?: string;
|
||
uid: string;
|
||
summary?: string;
|
||
dtStart?: string;
|
||
dtEnd?: string;
|
||
timeZone?: string;
|
||
isAllDay?: boolean;
|
||
sequence?: number;
|
||
status: 'ACCEPTED' | 'TENTATIVE' | 'DECLINED';
|
||
identityId?: string;
|
||
}): Promise<void> {
|
||
if (!opts.uid) {
|
||
debug.warn('calendar', '[iMIP] sendImipReply aborted: missing UID');
|
||
return;
|
||
}
|
||
const mailboxes = await this.getMailboxes();
|
||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||
if (!sentMailbox) {
|
||
throw new Error('No sent mailbox found');
|
||
}
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
if (!draftsMailbox) {
|
||
throw new Error('No drafts mailbox found');
|
||
}
|
||
|
||
let finalIdentityId = opts.identityId;
|
||
if (!finalIdentityId) {
|
||
const identityResponse = await this.request([
|
||
["Identity/get", { accountId: this.accountId }, "0"]
|
||
]);
|
||
if (identityResponse.methodResponses?.[0]?.[0] === "Identity/get") {
|
||
const identities = (identityResponse.methodResponses[0][1].list || []) as { id: string; email: string }[];
|
||
const match = identities.find((id) => id.email === opts.attendeeEmail);
|
||
finalIdentityId = match?.id || identities[0]?.id || this.accountId;
|
||
} else {
|
||
finalIdentityId = this.accountId;
|
||
}
|
||
}
|
||
|
||
// Build iCalendar REPLY (RFC 5546 §3.2.3)
|
||
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||
|
||
// Format a JSCalendar date string into iCalendar format
|
||
const formatIcalDate = (dateStr: string, tz?: string): string => {
|
||
// If it's an ISO UTC string (ends with Z), convert to iCalendar UTC format
|
||
if (dateStr.endsWith('Z')) {
|
||
return dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||
}
|
||
// Local date-time: strip punctuation, keep as-is for TZID parameter
|
||
const basic = dateStr.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||
if (tz) {
|
||
return `TZID=${tz}:${basic}`;
|
||
}
|
||
return basic;
|
||
};
|
||
|
||
const lines: string[] = [
|
||
'BEGIN:VCALENDAR',
|
||
'PRODID:-//JMAP-Webmail//EN',
|
||
'VERSION:2.0',
|
||
'CALSCALE:GREGORIAN',
|
||
'METHOD:REPLY',
|
||
'BEGIN:VEVENT',
|
||
`UID:${opts.uid}`,
|
||
`DTSTAMP:${now}`,
|
||
];
|
||
if (opts.dtStart) {
|
||
if (opts.isAllDay) {
|
||
// RFC 5545 §3.3.4: all-day events use VALUE=DATE (date-only, no time)
|
||
const dateOnly = opts.dtStart.replace(/[-]/g, '').substring(0, 8);
|
||
lines.push(`DTSTART;VALUE=DATE:${dateOnly}`);
|
||
} else {
|
||
const formatted = formatIcalDate(opts.dtStart, opts.timeZone);
|
||
if (formatted.startsWith('TZID=')) {
|
||
lines.push(`DTSTART;${formatted}`);
|
||
} else {
|
||
lines.push(`DTSTART:${formatted}`);
|
||
}
|
||
}
|
||
}
|
||
if (opts.dtEnd) {
|
||
if (opts.isAllDay) {
|
||
const dateOnly = opts.dtEnd.replace(/[-]/g, '').substring(0, 8);
|
||
lines.push(`DTEND;VALUE=DATE:${dateOnly}`);
|
||
} else {
|
||
const formatted = formatIcalDate(opts.dtEnd, opts.timeZone);
|
||
if (formatted.startsWith('TZID=')) {
|
||
lines.push(`DTEND;${formatted}`);
|
||
} else {
|
||
lines.push(`DTEND:${formatted}`);
|
||
}
|
||
}
|
||
}
|
||
if (opts.summary) {
|
||
lines.push(`SUMMARY:${escapeIcsText(opts.summary)}`);
|
||
}
|
||
if (opts.sequence != null) {
|
||
lines.push(`SEQUENCE:${opts.sequence}`);
|
||
}
|
||
const orgCn = opts.organizerName ? `;CN=${icsParamValue(opts.organizerName)}` : '';
|
||
lines.push(`ORGANIZER${orgCn}:mailto:${opts.organizerEmail}`);
|
||
const attCn = opts.attendeeName ? `;CN=${icsParamValue(opts.attendeeName)}` : '';
|
||
lines.push(`ATTENDEE;PARTSTAT=${opts.status}${attCn}:mailto:${opts.attendeeEmail}`);
|
||
lines.push('END:VEVENT');
|
||
lines.push('END:VCALENDAR');
|
||
const icsContent = lines.map(foldIcsLine).join('\r\n') + '\r\n';
|
||
|
||
debug.log('calendar', '[iMIP] Generated ICS:\n' + icsContent);
|
||
|
||
const statusLabels: Record<string, string> = {
|
||
ACCEPTED: 'Accepted',
|
||
TENTATIVE: 'Tentative',
|
||
DECLINED: 'Declined',
|
||
};
|
||
const statusLabel = statusLabels[opts.status] || opts.status;
|
||
const subject = `${statusLabel}: ${opts.summary || 'Event'}`;
|
||
|
||
debug.log('calendar', '[iMIP] identityId:', finalIdentityId);
|
||
|
||
const emailId = `imip-reply-${Date.now()}`;
|
||
const emailCreate: Record<string, unknown> = {
|
||
from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }],
|
||
to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }],
|
||
subject,
|
||
keywords: { "$seen": true, "$draft": true },
|
||
mailboxIds: { [draftsMailbox.id]: true },
|
||
bodyStructure: {
|
||
// RFC 6047 §3 requires multipart/mixed when a text/calendar part is present.
|
||
// Using multipart/alternative causes most clients to ignore the iTIP method.
|
||
// @see https://www.rfc-editor.org/rfc/rfc6047#section-3
|
||
// @see https://devguide.calconnect.org/iMIP/iMIPBest-Practices/
|
||
// Note: Gmail-to-Gmail events use Google's internal scheduling API, not iMIP.
|
||
// This fix targets non-Gmail organizers and external CalDAV servers.
|
||
type: 'multipart/mixed',
|
||
subParts: [
|
||
{ partId: 'text', type: 'text/plain' },
|
||
{ partId: 'cal', type: 'text/calendar; method=REPLY; charset=UTF-8', disposition: 'inline', name: 'reply.ics' },
|
||
],
|
||
},
|
||
bodyValues: {
|
||
text: { value: `${opts.attendeeName || opts.attendeeEmail} has ${statusLabel.toLowerCase()} the invitation to: ${opts.summary || 'Event'}` },
|
||
cal: { value: icsContent },
|
||
},
|
||
};
|
||
|
||
const methodCalls: JMAPMethodCall[] = [
|
||
["Email/set", {
|
||
accountId: this.accountId,
|
||
create: { [emailId]: emailCreate },
|
||
}, "0"],
|
||
["EmailSubmission/set", {
|
||
accountId: this.getSubmissionAccountId(),
|
||
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
|
||
onSuccessUpdateEmail: {
|
||
"#sub-1": {
|
||
...mailboxIdsReplacement(sentMailbox.id),
|
||
"keywords/$draft": null,
|
||
},
|
||
},
|
||
}, "1"],
|
||
];
|
||
|
||
debug.log('calendar', '[iMIP] Sending JMAP request with', methodCalls.length, 'method calls');
|
||
debug.log('calendar', '[iMIP] Email create payload:', JSON.stringify(emailCreate, null, 2));
|
||
|
||
const response = await this.request(methodCalls);
|
||
|
||
debug.log('calendar', '[iMIP] JMAP response:', JSON.stringify(response.methodResponses, null, 2));
|
||
|
||
if (response.methodResponses) {
|
||
for (const [methodName, result] of response.methodResponses) {
|
||
if (methodName.endsWith('/error')) {
|
||
debug.error('[iMIP] method error:', methodName, result);
|
||
throw new Error(result.description || `iMIP reply failed: ${result.type}`);
|
||
}
|
||
if (result.notCreated) {
|
||
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||
debug.error('[iMIP] create error:', JSON.stringify(result.notCreated, null, 2));
|
||
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP reply');
|
||
}
|
||
}
|
||
}
|
||
debug.log('calendar', '[iMIP] sendImipReply completed successfully');
|
||
}
|
||
|
||
/**
|
||
* Send an iMIP (RFC 6047) REQUEST email to all participants of a calendar event.
|
||
*
|
||
* Fallback only: when `sendSchedulingMessages` is passed to CalendarEvent/set,
|
||
* the server (Stalwart) queues the iTIP messages itself - calling this in
|
||
* addition produces duplicate invitation emails. Note the generated ICS is
|
||
* a minimal snapshot (no RRULE/VTIMEZONE), so server-side scheduling should
|
||
* always be preferred.
|
||
*/
|
||
async sendImipInvitation(event: CalendarEvent): Promise<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);
|
||
|
||
if (response.methodResponses) {
|
||
for (const [methodName, result] of response.methodResponses) {
|
||
if (methodName.endsWith('/error')) {
|
||
throw new Error(result.description || `iMIP invitation failed: ${result.type}`);
|
||
}
|
||
if (result.notCreated) {
|
||
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP invitation');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Send an iMIP (RFC 6047) CANCEL email to all participants of a calendar event.
|
||
*
|
||
* Fallback only: when `sendSchedulingMessages` is passed to the
|
||
* CalendarEvent/set destroy, the server sends the iTIP CANCEL itself -
|
||
* calling this in addition produces duplicate cancellation emails.
|
||
*/
|
||
async sendImipCancellation(event: CalendarEvent): Promise<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);
|
||
|
||
if (response.methodResponses) {
|
||
for (const [methodName, result] of response.methodResponses) {
|
||
if (methodName.endsWith('/error')) {
|
||
throw new Error(result.description || `iMIP cancellation failed: ${result.type}`);
|
||
}
|
||
if (result.notCreated) {
|
||
const firstError = Object.values(result.notCreated)[0] as { description?: string; type?: string };
|
||
throw new Error(firstError?.description || firstError?.type || 'Failed to send iMIP cancellation');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private xhrUpload(
|
||
url: string,
|
||
file: File,
|
||
onProgress?: (loaded: number, total: number) => void,
|
||
signal?: AbortSignal,
|
||
): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
if (signal?.aborted) {
|
||
reject(new DOMException('Upload aborted', 'AbortError'));
|
||
return;
|
||
}
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('POST', url, true);
|
||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
|
||
xhr.setRequestHeader('Authorization', this.authHeader);
|
||
xhr.responseType = 'text';
|
||
|
||
const onAbort = () => xhr.abort();
|
||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||
const cleanup = () => signal?.removeEventListener('abort', onAbort);
|
||
|
||
if (onProgress) {
|
||
// Fire 0% immediately so the UI leaves its initial state even
|
||
// before the first network packet flushes.
|
||
onProgress(0, file.size);
|
||
xhr.upload.onprogress = (ev) => {
|
||
// ev.total is only meaningful when lengthComputable; fall back
|
||
// to file.size so callers always get a usable denominator.
|
||
const total = ev.lengthComputable ? ev.total : file.size;
|
||
onProgress(ev.loaded, total);
|
||
};
|
||
}
|
||
|
||
xhr.onload = () => {
|
||
cleanup();
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
resolve(xhr.responseText);
|
||
} else {
|
||
reject(new Error(`Failed to upload file: ${xhr.status} - ${xhr.responseText}`));
|
||
}
|
||
};
|
||
xhr.onerror = () => { cleanup(); reject(new Error('Upload network error')); };
|
||
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload aborted', 'AbortError')); };
|
||
|
||
xhr.send(file);
|
||
});
|
||
}
|
||
|
||
// Signature accepts either the legacy positional accountId string OR
|
||
// the options bag introduced for progress / signal so existing
|
||
// call-sites keep compiling without touching every plugin.
|
||
async uploadBlob(
|
||
file: File,
|
||
optsOrAccountId?:
|
||
| string
|
||
| {
|
||
accountId?: string;
|
||
onProgress?: (loaded: number, total: number) => void;
|
||
signal?: AbortSignal;
|
||
},
|
||
): Promise<{ blobId: string; size: number; type: string }> {
|
||
const opts =
|
||
typeof optsOrAccountId === 'string'
|
||
? { accountId: optsOrAccountId }
|
||
: optsOrAccountId ?? {};
|
||
if (!this.session) {
|
||
throw new Error('Not connected. Call connect() first.');
|
||
}
|
||
|
||
const uploadUrl = this.session.uploadUrl;
|
||
if (!uploadUrl) {
|
||
throw new Error('Upload URL not available');
|
||
}
|
||
|
||
const targetAccountId = opts.accountId || this.accountId;
|
||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
|
||
|
||
// XHR path: fetch() does not expose upload progress events, so when the
|
||
// caller wants progress (or an AbortSignal) we use XMLHttpRequest. The
|
||
// fetch path is kept for callers that don't need either, to preserve
|
||
// existing 401/retry behaviour through authenticatedFetch().
|
||
let responseText: string;
|
||
if (opts.onProgress || opts.signal) {
|
||
responseText = await this.xhrUpload(
|
||
finalUploadUrl,
|
||
file,
|
||
opts.onProgress,
|
||
opts.signal,
|
||
);
|
||
} else {
|
||
const response = await this.authenticatedFetch(finalUploadUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||
body: file,
|
||
}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS });
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`Failed to upload file: ${response.status} - ${errorText}`);
|
||
}
|
||
responseText = await response.text();
|
||
}
|
||
let result;
|
||
try {
|
||
result = JSON.parse(responseText);
|
||
} catch {
|
||
throw new Error('Invalid JSON response from upload');
|
||
}
|
||
|
||
// Direct format: { blobId, type, size }
|
||
if (result.blobId) {
|
||
return {
|
||
blobId: result.blobId,
|
||
size: result.size || file.size,
|
||
type: result.type || file.type,
|
||
};
|
||
}
|
||
|
||
// Nested format: { [accountId]: { blobId, type, size } }
|
||
const blobInfo = result[targetAccountId];
|
||
if (blobInfo?.blobId) {
|
||
return {
|
||
blobId: blobInfo.blobId,
|
||
size: blobInfo.size || file.size,
|
||
type: blobInfo.type || file.type,
|
||
};
|
||
}
|
||
|
||
throw new Error('Invalid upload response: blobId not found');
|
||
}
|
||
|
||
/**
|
||
* Import a raw RFC822 message (referenced by a previously-uploaded blob) into
|
||
* one or more mailboxes. Returns the new email id. Used for sending MDNs,
|
||
* where the exact MIME bytes must be preserved (Email/set can't express a
|
||
* multipart/report report-type parameter reliably).
|
||
*/
|
||
async importEmail(
|
||
blobId: string,
|
||
mailboxIds: Record<string, boolean>,
|
||
keywords?: Record<string, boolean>,
|
||
accountId?: string
|
||
): Promise<string | null> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const creationId = `imp-${Date.now()}`;
|
||
const response = await this.request([
|
||
["Email/import", {
|
||
accountId: targetAccountId,
|
||
emails: {
|
||
[creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } },
|
||
},
|
||
}, "0"],
|
||
]);
|
||
const res = response.methodResponses?.[0];
|
||
if (res?.[0] !== "Email/import") {
|
||
console.error('Email/import: unexpected response', res);
|
||
return null;
|
||
}
|
||
const payload = res[1] as {
|
||
created?: Record<string, { id: string }>;
|
||
notCreated?: Record<string, { type?: string; description?: string }>;
|
||
};
|
||
const created = payload?.created?.[creationId];
|
||
if (!created) {
|
||
const reason = payload?.notCreated?.[creationId];
|
||
console.error('Email/import failed:', reason || payload);
|
||
throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`);
|
||
}
|
||
return created.id;
|
||
}
|
||
|
||
/**
|
||
* Send an RFC 8098 Message Disposition Notification (read receipt) in reply
|
||
* to a message that carried a Disposition-Notification-To header. Builds the
|
||
* multipart/report, uploads it as a blob, imports it into Sent, then submits
|
||
* it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the
|
||
* requesting address).
|
||
*/
|
||
async sendReadReceipt(params: {
|
||
to: string;
|
||
fromEmail: string;
|
||
fromName?: string;
|
||
identityId: string;
|
||
originalMessageId?: string | string[];
|
||
originalSubject?: string;
|
||
originalRecipient?: string;
|
||
automatic?: boolean;
|
||
accountId?: string;
|
||
subject?: string;
|
||
humanText?: string;
|
||
}): Promise<void> {
|
||
const targetAccountId = params.accountId || this.accountId;
|
||
const { buildMdnMessage } = await import("@/lib/mdn");
|
||
const raw = buildMdnMessage(params);
|
||
|
||
const file = new File([raw], "receipt.eml", { type: "message/rfc822" });
|
||
const { blobId } = await this.uploadBlob(file);
|
||
|
||
const mailboxes = await this.getMailboxes();
|
||
const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0];
|
||
if (!targetMailbox) throw new Error('No mailbox available for MDN import');
|
||
|
||
const emailId = await this.importEmail(
|
||
blobId,
|
||
{ [targetMailbox.id]: true },
|
||
{ "$seen": true },
|
||
targetAccountId
|
||
);
|
||
if (!emailId) throw new Error('MDN import failed');
|
||
|
||
const subId = `mdnsub-${Date.now()}`;
|
||
const response = await this.request([
|
||
["EmailSubmission/set", {
|
||
accountId: targetAccountId,
|
||
create: {
|
||
[subId]: {
|
||
emailId,
|
||
identityId: params.identityId,
|
||
envelope: {
|
||
mailFrom: { email: params.fromEmail },
|
||
rcptTo: [{ email: params.to }],
|
||
},
|
||
},
|
||
},
|
||
}, "0"],
|
||
]);
|
||
const subRes = response.methodResponses?.[0];
|
||
const notCreated = (subRes?.[1] as { notCreated?: Record<string, { type?: string; description?: string }> })?.notCreated?.[subId];
|
||
if (notCreated) {
|
||
throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`);
|
||
}
|
||
}
|
||
|
||
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string {
|
||
if (!this.downloadUrl) {
|
||
throw new Error('Download URL not available. Please reconnect.');
|
||
}
|
||
|
||
// RFC 6570 level 1 URI template expansion. Blobs are scoped per account,
|
||
// so a caller fetching a blob from a delegated/shared account must pass
|
||
// that owner's accountId rather than defaulting to the primary one.
|
||
return this.downloadUrl
|
||
.replace('{accountId}', encodeURIComponent(accountId || this.accountId))
|
||
.replace('{blobId}', encodeURIComponent(blobId))
|
||
.replace('{name}', encodeURIComponent(name || 'download'))
|
||
.replace('{type}', encodeURIComponent(type || 'application/octet-stream'));
|
||
}
|
||
|
||
async fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob> {
|
||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||
const response = await this.authenticatedFetch(url, {}, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS });
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||
}
|
||
return response.blob();
|
||
}
|
||
|
||
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string> {
|
||
const blob = await this.fetchBlob(blobId, name, type, accountId);
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
getCapabilities(): Record<string, unknown> {
|
||
return this.capabilities;
|
||
}
|
||
|
||
hasCapability(capability: string): boolean {
|
||
return capability in this.capabilities;
|
||
}
|
||
|
||
/** Check whether a capability is present on the primary account. */
|
||
hasAccountCapability(capability: string, accountId?: string): boolean {
|
||
const id = accountId || this.accountId;
|
||
const caps = this.session?.accounts?.[id]?.accountCapabilities;
|
||
return !!caps && capability in caps;
|
||
}
|
||
|
||
getMaxSizeUpload(): number {
|
||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxSizeUpload?: number } | undefined;
|
||
return coreCapability?.maxSizeUpload || 0;
|
||
}
|
||
|
||
getMaxCallsInRequest(): number {
|
||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxCallsInRequest?: number } | undefined;
|
||
return coreCapability?.maxCallsInRequest || 50;
|
||
}
|
||
|
||
getMaxObjectsInGet(): number {
|
||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInGet?: number } | undefined;
|
||
return coreCapability?.maxObjectsInGet || 500;
|
||
}
|
||
|
||
getMaxObjectsInSet(): number {
|
||
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInSet?: number } | undefined;
|
||
return coreCapability?.maxObjectsInSet || 500;
|
||
}
|
||
|
||
getMaxDelayedSend(accountId?: string): number {
|
||
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
|
||
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
|
||
}
|
||
|
||
hasDelayedSend(accountId?: string): boolean {
|
||
const submissionCapability = this.getSubmissionCapability(accountId);
|
||
const submissionExtensions = submissionCapability?.submissionExtensions;
|
||
const hasFutureRelease = this.hasSubmissionExtension(submissionExtensions, 'FUTURERELEASE');
|
||
|
||
return !!submissionCapability
|
||
&& hasFutureRelease
|
||
&& this.getMaxDelayedSend(accountId) > 0;
|
||
}
|
||
|
||
private validateDelayedUntil(delayedUntil: string, accountId?: string): number {
|
||
const time = new Date(delayedUntil).getTime();
|
||
if (!Number.isFinite(time)) {
|
||
throw new Error('Scheduled send time is invalid');
|
||
}
|
||
const now = Date.now();
|
||
if (time <= now) {
|
||
throw new Error('Scheduled send time must be in the future');
|
||
}
|
||
const maxDelayedSend = this.getMaxDelayedSend(accountId);
|
||
if (!this.hasDelayedSend(accountId) || maxDelayedSend <= 0) {
|
||
throw new Error('Scheduled send is not supported for this account');
|
||
}
|
||
if (time > now + maxDelayedSend * 1000) {
|
||
throw new Error('Scheduled send time is later than the server allows');
|
||
}
|
||
return Math.ceil((time - now) / 1000);
|
||
}
|
||
|
||
private async getEmailSubmissionSendAt(submissionId: string, accountId?: string): Promise<string | undefined> {
|
||
const response = await this.request([
|
||
['EmailSubmission/get', {
|
||
accountId: accountId ?? await this.resolveSubmissionAccountId(submissionId),
|
||
ids: [submissionId],
|
||
properties: ['sendAt', 'undoStatus'],
|
||
}, '0'],
|
||
]);
|
||
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { sendAt?: string } | undefined;
|
||
return submission?.sendAt;
|
||
}
|
||
|
||
private async getEmailSubmissionEnvelope(submissionId: string, accountId?: string): Promise<{ rcptTo?: Array<{ email: string }> } | undefined> {
|
||
const response = await this.request([
|
||
['EmailSubmission/get', {
|
||
accountId: accountId ?? await this.resolveSubmissionAccountId(submissionId),
|
||
ids: [submissionId],
|
||
properties: ['envelope'],
|
||
}, '0'],
|
||
]);
|
||
const submission = response.methodResponses?.[0]?.[1]?.list?.[0] as { envelope?: { rcptTo?: Array<{ email: string }> } } | undefined;
|
||
return submission?.envelope;
|
||
}
|
||
|
||
private getSubmissionAccountId(accountId?: string): string {
|
||
// The requested (mail) account may not host EmailSubmission objects — JMAP
|
||
// allows submission to live in a separate account (session
|
||
// primaryAccounts['…:submission']). Only honour the requested account when
|
||
// it actually advertises the submission capability; otherwise fall back to
|
||
// the account JMAP designates for submission.
|
||
const submissionPrimary = this.session?.primaryAccounts?.['urn:ietf:params:jmap:submission'];
|
||
if (accountId && this.session?.accounts?.[accountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission']) {
|
||
return accountId;
|
||
}
|
||
return submissionPrimary || accountId || this.accountId;
|
||
}
|
||
|
||
/**
|
||
* Every account whose submissions this session can see: the primary
|
||
* submission account plus each shared/group account that advertises the
|
||
* submission capability in its own right.
|
||
*
|
||
* A delayed send composed from a shared identity is created in the *shared*
|
||
* account (sendEmail passes the target account to getSubmissionAccountId),
|
||
* so a scheduled-mail lookup that only ever asks the primary account misses
|
||
* it entirely: the message stays pending on the server and goes out at
|
||
* sendAt, but the UI cannot list, cancel or reschedule it. See #874.
|
||
*/
|
||
private getSubmissionAccountIds(): string[] {
|
||
const ids: string[] = [];
|
||
const primary = this.getSubmissionAccountId();
|
||
if (primary) ids.push(primary);
|
||
for (const [accountId, account] of Object.entries(this.session?.accounts ?? {})) {
|
||
if (ids.includes(accountId)) continue;
|
||
if (account?.accountCapabilities?.['urn:ietf:params:jmap:submission']) ids.push(accountId);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/**
|
||
* Locate the account that actually holds a submission. Shared-account
|
||
* submissions are invisible to (and not updatable through) the primary
|
||
* account, which answers `notFound`. Resolved by asking each submission
|
||
* account in turn, primary first.
|
||
*/
|
||
private async resolveSubmissionAccountId(submissionId: string): Promise<string> {
|
||
const accountIds = this.getSubmissionAccountIds();
|
||
if (accountIds.length <= 1) return accountIds[0] ?? this.getSubmissionAccountId();
|
||
for (const accountId of accountIds) {
|
||
try {
|
||
const response = await this.request([
|
||
['EmailSubmission/get', { accountId, ids: [submissionId], properties: ['id'] }, '0'],
|
||
]);
|
||
const list = response.methodResponses?.[0]?.[1]?.list as Array<{ id?: string }> | undefined;
|
||
if (list?.length) return accountId;
|
||
} catch {
|
||
// An account that cannot answer for this submission is simply not the
|
||
// owner; keep looking rather than failing the whole operation.
|
||
}
|
||
}
|
||
return this.getSubmissionAccountId();
|
||
}
|
||
|
||
private getSubmissionCapability(accountId?: string): SubmissionCapability | undefined {
|
||
const submissionAccountId = this.getSubmissionAccountId(accountId);
|
||
return this.session?.accounts?.[submissionAccountId]?.accountCapabilities?.['urn:ietf:params:jmap:submission'] as SubmissionCapability | undefined;
|
||
}
|
||
|
||
/**
|
||
* Whether the submission account advertises an SMTP extension (RFC 8621
|
||
* §1.3 `submissionExtensions`), e.g. "DSN" or "REQUIRETLS".
|
||
*/
|
||
supportsSubmissionExtension(extension: string, accountId?: string): boolean {
|
||
return this.hasSubmissionExtension(this.getSubmissionCapability(accountId)?.submissionExtensions, extension);
|
||
}
|
||
|
||
private hasSubmissionExtension(submissionExtensions: unknown, extension: string): boolean {
|
||
const target = extension.toUpperCase();
|
||
if (Array.isArray(submissionExtensions)) {
|
||
return submissionExtensions.some(item => typeof item === 'string' && item.toUpperCase() === target);
|
||
}
|
||
if (submissionExtensions && typeof submissionExtensions === 'object') {
|
||
return Object.entries(submissionExtensions as Record<string, unknown>)
|
||
.some(([key, value]) => key.toUpperCase() === target && value !== false && value != null);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
getEventSourceUrl(): string | null {
|
||
if (!this.session) return null;
|
||
|
||
// RFC 8620: session root level, with fallback to capabilities for some servers
|
||
const coreCapability = this.session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined;
|
||
return this.session.eventSourceUrl || coreCapability?.eventSourceUrl || null;
|
||
}
|
||
|
||
getAccountId(): string {
|
||
return this.accountId;
|
||
}
|
||
|
||
getUsername(): string {
|
||
return this.username || this.session?.accounts?.[this.accountId]?.name || '';
|
||
}
|
||
|
||
// Server-confirmed authenticated login from the JMAP Session object. Use
|
||
// this (not getUsername(), which echoes the constructor arg, nor the
|
||
// sending identity) to verify a slot's token resolved to the expected
|
||
// account.
|
||
getSessionUsername(): string | undefined {
|
||
return this.session?.username;
|
||
}
|
||
|
||
supportsEmailSubmission(): boolean {
|
||
return this.hasCapability("urn:ietf:params:jmap:submission");
|
||
}
|
||
|
||
supportsQuota(): boolean {
|
||
return this.hasCapability("urn:ietf:params:jmap:quota");
|
||
}
|
||
|
||
supportsVacationResponse(): boolean {
|
||
return this.hasCapability("urn:ietf:params:jmap:vacationresponse");
|
||
}
|
||
|
||
supportsContacts(accountId?: string): boolean {
|
||
// Gate on the ACCOUNT capability: a server can advertise contacts while an
|
||
// account has its jmap-contact-* / dav-card-* permissions revoked. Shared accounts
|
||
// aren't always advertised per-account, so fall back to the server-wide
|
||
// capability - accountCapabilities is a subset of it (RFC 8620 s2).
|
||
const id = accountId || this.accountId;
|
||
const account = this.accounts[id];
|
||
if (!account) return false;
|
||
if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"]) return true;
|
||
return !account.isPersonal && !!this.capabilities?.["urn:ietf:params:jmap:contacts"];
|
||
}
|
||
|
||
supportsCalendars(accountId?: string): boolean {
|
||
// Gate on the ACCOUNT capability: a server can advertise calendars while an
|
||
// account has its jmap-calendar-* / dav-cal-* permissions revoked. Shared accounts
|
||
// aren't always advertised per-account, so fall back to the server-wide
|
||
// capability - accountCapabilities is a subset of it (RFC 8620 s2).
|
||
const id = accountId || this.accountId;
|
||
const account = this.accounts[id];
|
||
if (!account) return false;
|
||
if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"]) return true;
|
||
return !account.isPersonal && !!this.capabilities?.["urn:ietf:params:jmap:calendars"];
|
||
}
|
||
|
||
supportsSieve(): boolean {
|
||
return this.hasCapability("urn:ietf:params:jmap:sieve");
|
||
}
|
||
|
||
supportsPrincipals(): boolean {
|
||
return this.hasCapability("urn:ietf:params:jmap:principals");
|
||
}
|
||
|
||
getSieveAccountId(): string {
|
||
const sieveAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:sieve"];
|
||
return sieveAccount || this.accountId;
|
||
}
|
||
|
||
private sieveUsing(): string[] {
|
||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"];
|
||
}
|
||
|
||
/**
|
||
* Accounts (primary + shared/group) visible in this session, each tagged with
|
||
* the capabilities it advertises. Unifies the per-feature enumeration that
|
||
* getCalendarCapableAccountIds()/getContactCapableAccountIds()/
|
||
* getFilesCapableAccountIds() do: a non-primary account is included when it
|
||
* advertises a capability OR is a non-personal (shared/group) account, since
|
||
* Stalwart doesn't always advertise capabilities on those even when they
|
||
* support the feature. Primary account first; name from the session account
|
||
* (primary falls back to the username). Drives the settings "Shared with me"
|
||
* list and the scoped-settings tab gating.
|
||
*/
|
||
getSharedAccounts(): SharedAccount[] {
|
||
const primaryId = this.getSieveAccountId();
|
||
const toEntry = (id: string, isPrimary: boolean): SharedAccount => {
|
||
const account = this.accounts[id];
|
||
const caps = account?.accountCapabilities;
|
||
const shared = !account?.isPersonal;
|
||
return {
|
||
id,
|
||
name: account?.name || (isPrimary ? (this.username || id) : id),
|
||
isPrimary,
|
||
capabilities: {
|
||
mail: !!caps?.["urn:ietf:params:jmap:mail"] || shared,
|
||
sieve: !!caps?.["urn:ietf:params:jmap:sieve"] || shared,
|
||
calendars: !!caps?.["urn:ietf:params:jmap:calendars"] || shared,
|
||
contacts: !!caps?.["urn:ietf:params:jmap:contacts"] || shared,
|
||
filenode: !!caps?.["urn:ietf:params:jmap:filenode"] || shared,
|
||
},
|
||
};
|
||
};
|
||
|
||
const result = [toEntry(primaryId, true)];
|
||
for (const id of Object.keys(this.accounts)) {
|
||
if (id === primaryId) continue;
|
||
const account = this.accounts[id];
|
||
// Mirror the per-feature helpers: include any non-personal account, plus
|
||
// accounts that advertise at least one editable capability.
|
||
const caps = account.accountCapabilities;
|
||
const advertisesAny =
|
||
!!caps?.["urn:ietf:params:jmap:mail"] ||
|
||
!!caps?.["urn:ietf:params:jmap:sieve"] ||
|
||
!!caps?.["urn:ietf:params:jmap:calendars"] ||
|
||
!!caps?.["urn:ietf:params:jmap:contacts"] ||
|
||
!!caps?.["urn:ietf:params:jmap:filenode"];
|
||
if (!account.isPersonal || advertisesAny) {
|
||
result.push(toEntry(id, false));
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Sieve-capable accounts (primary + shared/group), for the filters UI.
|
||
* Thin wrapper over getSharedAccounts() filtered to the sieve capability.
|
||
*/
|
||
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
|
||
return this.getSharedAccounts()
|
||
.filter((a) => a.isPrimary || a.capabilities.sieve)
|
||
.map(({ id, name, isPrimary }) => ({ id, name, isPrimary }));
|
||
}
|
||
|
||
getSieveCapabilities(accountId?: string): SieveCapabilities | null {
|
||
const sieveAccountId = accountId || this.getSieveAccountId();
|
||
const accountInfo = this.accounts[sieveAccountId];
|
||
if (!accountInfo?.accountCapabilities) return null;
|
||
const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"];
|
||
return (caps as SieveCapabilities) || null;
|
||
}
|
||
|
||
async getSieveScripts(accountId?: string): Promise<SieveScript[]> {
|
||
const response = await this.request([
|
||
["SieveScript/get", {
|
||
accountId: accountId || this.getSieveAccountId(),
|
||
}, "0"]
|
||
], this.sieveUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "SieveScript/get") {
|
||
return (response.methodResponses[0][1].list || []) as SieveScript[];
|
||
}
|
||
throw new Error('Failed to fetch Sieve scripts');
|
||
}
|
||
|
||
async getSieveScriptContent(blobId: string, accountId?: string): Promise<string> {
|
||
// Blobs are scoped per account, so a shared/group account's script must be
|
||
// downloaded against that owner's accountId, not the primary one.
|
||
const url = this.getBlobDownloadUrl(
|
||
blobId, 'script.sieve', 'application/sieve', accountId || this.getSieveAccountId(),
|
||
);
|
||
const response = await this.authenticatedFetch(url, {});
|
||
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
|
||
return response.text();
|
||
}
|
||
|
||
private async uploadSieveBlob(content: string, accountId?: string): Promise<string> {
|
||
if (!this.session?.uploadUrl) {
|
||
throw new Error('Upload URL not available');
|
||
}
|
||
|
||
const targetAccountId = accountId || this.getSieveAccountId();
|
||
const uploadUrl = this.session.uploadUrl.replace(
|
||
'{accountId}',
|
||
encodeURIComponent(targetAccountId)
|
||
);
|
||
|
||
const response = await this.authenticatedFetch(uploadUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/sieve',
|
||
},
|
||
body: content,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text();
|
||
throw new Error(`Failed to upload sieve script: ${response.status} - ${errorText.substring(0, 200)}`);
|
||
}
|
||
|
||
const result = await response.json();
|
||
if (result.blobId) return result.blobId;
|
||
const blobInfo = result[targetAccountId];
|
||
if (blobInfo?.blobId) return blobInfo.blobId;
|
||
throw new Error('Invalid upload response: blobId not found');
|
||
}
|
||
|
||
async createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript> {
|
||
const targetAccountId = accountId || this.getSieveAccountId();
|
||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
accountId: targetAccountId,
|
||
create: {
|
||
"new-script": { name, blobId }
|
||
},
|
||
};
|
||
if (activate) {
|
||
setArgs.onSuccessActivateScript = "#new-script";
|
||
}
|
||
|
||
const response = await this.request([
|
||
["SieveScript/set", setArgs, "0"]
|
||
], this.sieveUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
||
const result = response.methodResponses[0][1];
|
||
if (result.notCreated?.["new-script"]) {
|
||
const error = result.notCreated["new-script"];
|
||
throw new Error(error.description || "Failed to create sieve script");
|
||
}
|
||
const createdId = result.created?.["new-script"]?.id;
|
||
if (createdId) {
|
||
const scripts = await this.getSieveScripts(targetAccountId);
|
||
const script = scripts.find(s => s.id === createdId);
|
||
if (script) return script;
|
||
}
|
||
}
|
||
throw new Error("Failed to create sieve script");
|
||
}
|
||
|
||
async updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void> {
|
||
const targetAccountId = accountId || this.getSieveAccountId();
|
||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
accountId: targetAccountId,
|
||
update: {
|
||
[scriptId]: { blobId }
|
||
},
|
||
};
|
||
if (activate) {
|
||
setArgs.onSuccessActivateScript = scriptId;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["SieveScript/set", setArgs, "0"]
|
||
], this.sieveUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
||
const result = response.methodResponses[0][1];
|
||
if (result.notUpdated?.[scriptId]) {
|
||
const error = result.notUpdated[scriptId];
|
||
throw new Error(error.description || "Failed to update sieve script");
|
||
}
|
||
return;
|
||
}
|
||
throw new Error("Failed to update sieve script");
|
||
}
|
||
|
||
async deleteSieveScript(scriptId: string, accountId?: string): Promise<void> {
|
||
const response = await this.request([
|
||
["SieveScript/set", {
|
||
accountId: accountId || this.getSieveAccountId(),
|
||
destroy: [scriptId]
|
||
}, "0"]
|
||
], this.sieveUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "SieveScript/set") {
|
||
const result = response.methodResponses[0][1];
|
||
if (result.notDestroyed?.[scriptId]) {
|
||
const error = result.notDestroyed[scriptId];
|
||
throw new Error(error.description || "Failed to delete sieve script");
|
||
}
|
||
return;
|
||
}
|
||
throw new Error("Failed to delete sieve script");
|
||
}
|
||
|
||
async activateSieveScript(scriptId: string, accountId?: string): Promise<void> {
|
||
const response = await this.request([
|
||
["SieveScript/set", {
|
||
accountId: accountId || this.getSieveAccountId(),
|
||
onSuccessActivateScript: scriptId,
|
||
}, "0"]
|
||
], this.sieveUsing());
|
||
|
||
const [methodName, result] = response.methodResponses?.[0] || [];
|
||
if (methodName === "error") {
|
||
throw new Error(result?.description || "Failed to activate sieve script");
|
||
}
|
||
if (methodName !== "SieveScript/set") {
|
||
throw new Error("Failed to activate sieve script");
|
||
}
|
||
}
|
||
|
||
async deactivateSieveScript(accountId?: string): Promise<void> {
|
||
const response = await this.request([
|
||
["SieveScript/set", {
|
||
accountId: accountId || this.getSieveAccountId(),
|
||
onSuccessActivateScript: null,
|
||
}, "0"]
|
||
], this.sieveUsing());
|
||
|
||
const [methodName, result] = response.methodResponses?.[0] || [];
|
||
if (methodName === "error") {
|
||
throw new Error(result?.description || "Failed to deactivate sieve script");
|
||
}
|
||
if (methodName !== "SieveScript/set") {
|
||
throw new Error("Failed to deactivate sieve script");
|
||
}
|
||
}
|
||
|
||
async validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
||
const targetAccountId = accountId || this.getSieveAccountId();
|
||
const blobId = await this.uploadSieveBlob(content, targetAccountId);
|
||
|
||
const response = await this.request([
|
||
["SieveScript/validate", {
|
||
accountId: targetAccountId,
|
||
blobId,
|
||
}, "0"]
|
||
], this.sieveUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "SieveScript/validate") {
|
||
const result = response.methodResponses[0][1];
|
||
if (result.error) {
|
||
return { isValid: false, errors: [result.error.description || "Validation failed"] };
|
||
}
|
||
return { isValid: true };
|
||
}
|
||
|
||
if (response.methodResponses?.[0]?.[0]?.endsWith('/error')) {
|
||
const error = response.methodResponses[0][1];
|
||
return { isValid: false, errors: [error.description || "Validation failed"] };
|
||
}
|
||
|
||
return { isValid: false, errors: ['Unexpected validation response'] };
|
||
}
|
||
|
||
getContactsAccountId(): string {
|
||
const contactsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:contacts"];
|
||
return contactsAccount || this.accountId;
|
||
}
|
||
|
||
getCalendarsAccountId(): string {
|
||
const calendarsAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:calendars"];
|
||
return calendarsAccount || this.accountId;
|
||
}
|
||
|
||
private contactUsing(): string[] {
|
||
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:contacts"];
|
||
if (this.hasCapability("urn:ietf:params:jmap:principals:owner")) {
|
||
using.push("urn:ietf:params:jmap:principals:owner");
|
||
}
|
||
return using;
|
||
}
|
||
|
||
private calendarUsing(): string[] {
|
||
const using = ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:calendars"];
|
||
if (this.hasCapability("urn:ietf:params:jmap:principals:owner")) {
|
||
using.push("urn:ietf:params:jmap:principals:owner");
|
||
}
|
||
return using;
|
||
}
|
||
|
||
private getCalendarCapableAccountIds(): string[] {
|
||
const primaryId = this.getCalendarsAccountId();
|
||
const accountIds: string[] = [];
|
||
for (const [id, account] of Object.entries(this.accounts)) {
|
||
if (id === primaryId) continue;
|
||
// Include accounts that either advertise calendar capability
|
||
// or are non-personal (shared/group) accounts - Stalwart doesn't
|
||
// always advertise capabilities on group accounts even when they
|
||
// have calendar resources.
|
||
if (account.accountCapabilities?.["urn:ietf:params:jmap:calendars"] || account.isPersonal === false) {
|
||
accountIds.push(id);
|
||
}
|
||
}
|
||
return [primaryId, ...accountIds];
|
||
}
|
||
|
||
private getContactCapableAccountIds(): string[] {
|
||
const primaryId = this.getContactsAccountId();
|
||
const accountIds: string[] = [];
|
||
for (const [id, account] of Object.entries(this.accounts)) {
|
||
if (id === primaryId) continue;
|
||
// Include accounts that either advertise contacts capability
|
||
// or are non-personal (shared/group) accounts - Stalwart doesn't
|
||
// always advertise capabilities on group accounts even when they
|
||
// have contact resources.
|
||
if (account.accountCapabilities?.["urn:ietf:params:jmap:contacts"] || account.isPersonal === false) {
|
||
accountIds.push(id);
|
||
}
|
||
}
|
||
return [primaryId, ...accountIds];
|
||
}
|
||
|
||
async getAddressBooks(options?: { throwOnError?: boolean }): Promise<AddressBook[]> {
|
||
try {
|
||
const accountId = this.getContactsAccountId();
|
||
const response = await this.request([
|
||
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
||
return (response.methodResponses[0][1].list || []) as AddressBook[];
|
||
}
|
||
const methodError = response.methodResponses?.[0]?.[1];
|
||
throw new Error(methodError?.description || methodError?.type || "AddressBook/get failed");
|
||
} catch (error) {
|
||
console.error('Failed to get address books:', error);
|
||
// Callers that would treat an empty list as "nothing exists yet" must be
|
||
// able to tell a real empty account from a failed fetch (#730).
|
||
if (options?.throwOnError) throw error;
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async getAllAddressBooks(): Promise<AddressBook[]> {
|
||
try {
|
||
const allBooks: AddressBook[] = [];
|
||
const primaryId = this.getContactsAccountId();
|
||
const accountIds = this.getContactCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const response = await this.request([
|
||
["AddressBook/get", { accountId, properties: ADDRESS_BOOK_PROPERTIES }, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "AddressBook/get") {
|
||
const rawBooks = (response.methodResponses[0][1].list || []) as AddressBook[];
|
||
const books = rawBooks.map((book) => ({
|
||
...book,
|
||
id: isPrimary ? book.id : `${accountId}:${book.id}`,
|
||
originalId: book.id,
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}));
|
||
allBooks.push(...books);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to fetch address books for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allBooks;
|
||
} catch (error) {
|
||
console.error('Failed to fetch all address books:', error);
|
||
return this.getAddressBooks();
|
||
}
|
||
}
|
||
|
||
async createAddressBook(name: string): Promise<AddressBook> {
|
||
const accountId = this.getContactsAccountId();
|
||
const response = await this.request([
|
||
["AddressBook/set", {
|
||
accountId,
|
||
create: { "new-book": { name } },
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
|
||
const result = response.methodResponses[0][1];
|
||
const created = result.created?.["new-book"];
|
||
if (created) {
|
||
return { id: created.id, name, ...created } as AddressBook;
|
||
}
|
||
const err = result.notCreated?.["new-book"];
|
||
throw new Error(err?.description || "Failed to create address book");
|
||
}
|
||
throw new Error("Failed to create address book");
|
||
}
|
||
|
||
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
// Only forward server-settable properties. `isDefault` is deliberately not
|
||
// among them: it is read-only in AddressBook/set and including it fails the
|
||
// whole update with invalidProperties - use setDefaultAddressBook instead.
|
||
const { name, description, sortOrder, color } = updates as Record<string, unknown>;
|
||
const patch: Record<string, unknown> = {};
|
||
if (name !== undefined) patch.name = name;
|
||
if (description !== undefined) patch.description = description;
|
||
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||
if (color !== undefined) patch.color = color;
|
||
|
||
const response = await this.request([
|
||
["AddressBook/set", {
|
||
accountId,
|
||
update: { [addressBookId]: patch },
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "AddressBook/set") {
|
||
const result = response.methodResponses[0][1];
|
||
if (result.notUpdated?.[addressBookId]) {
|
||
const error = result.notUpdated[addressBookId];
|
||
throw new Error(error.description || "Failed to update address book");
|
||
}
|
||
return;
|
||
}
|
||
throw new Error("Failed to update address book");
|
||
}
|
||
|
||
/**
|
||
* Mark an address book as the account default. `isDefault` is read-only in
|
||
* AddressBook/set - the default is changed via the `onSuccessSetIsDefault`
|
||
* request argument instead (same shape as Calendar/set).
|
||
*/
|
||
async setDefaultAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
|
||
const response = await this.request([
|
||
["AddressBook/set", {
|
||
accountId,
|
||
onSuccessSetIsDefault: addressBookId,
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
const methodName = response.methodResponses?.[0]?.[0];
|
||
if (methodName === "error") {
|
||
const error = response.methodResponses?.[0]?.[1];
|
||
throw new Error(error?.description || error?.type || "Failed to set default address book");
|
||
}
|
||
if (methodName !== "AddressBook/set") {
|
||
throw new Error("Failed to set default address book");
|
||
}
|
||
}
|
||
|
||
async deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
const response = await this.request([
|
||
["AddressBook/set", { accountId, destroy: [addressBookId] }, "0"],
|
||
], this.contactUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notDestroyed?.[addressBookId]) {
|
||
const err = result.notDestroyed[addressBookId];
|
||
throw new Error(err.description || "Failed to delete address book");
|
||
}
|
||
}
|
||
|
||
// ── Sharing (RFC 9670) ──────────────────────────────────────────────────────
|
||
|
||
private principalsUsing(): string[] {
|
||
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:principals"];
|
||
}
|
||
|
||
/**
|
||
* List all principals visible to the user (RFC 9670). Stalwart returns the
|
||
* full directory regardless of `filter`, so we fetch the whole list and let
|
||
* callers filter client-side.
|
||
*/
|
||
async getPrincipals(targetAccountId?: string): Promise<Principal[]> {
|
||
if (!this.supportsPrincipals()) return [];
|
||
const accountId = targetAccountId || this.accountId;
|
||
try {
|
||
const response = await this.request([
|
||
["Principal/query", { accountId }, "0"],
|
||
["Principal/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "0", name: "Principal/query", path: "/ids" },
|
||
}, "1"],
|
||
], this.principalsUsing());
|
||
|
||
const getResp = response.methodResponses?.find((r) => r[0] === "Principal/get");
|
||
if (!getResp) return [];
|
||
const list = (getResp[1].list || []) as Principal[];
|
||
return list.map((p) => ({ ...p, accountId }));
|
||
} catch (error) {
|
||
console.error("Failed to fetch principals:", error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// ── CalendarEventNotification (draft-ietf-jmap-calendars §7) ───
|
||
|
||
/**
|
||
* Pending event notifications (invitations, updates, cancellations made by
|
||
* other participants), oldest first. `event` is requested for the title
|
||
* only; the default property set omits it.
|
||
*/
|
||
async getCalendarEventNotifications(): Promise<CalendarEventNotification[]> {
|
||
if (!this.supportsCalendars()) return [];
|
||
const accountId = this.getCalendarsAccountId();
|
||
const response = await this.request([
|
||
["CalendarEventNotification/query", {
|
||
accountId,
|
||
sort: [{ property: "created", isAscending: true }],
|
||
}, "0"],
|
||
["CalendarEventNotification/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "0", name: "CalendarEventNotification/query", path: "/ids" },
|
||
properties: ["id", "created", "changedBy", "comment", "type", "calendarEventId", "isDraft", "event"],
|
||
}, "1"],
|
||
], this.calendarUsing());
|
||
const getResp = response.methodResponses?.find((r) => r[0] === "CalendarEventNotification/get");
|
||
if (!getResp) {
|
||
const error = response.methodResponses?.find((r) => r[0] === "error")?.[1] as { description?: string } | undefined;
|
||
throw new Error(error?.description || "Failed to load calendar event notifications");
|
||
}
|
||
return ((getResp[1] as { list?: CalendarEventNotification[] }).list ?? []);
|
||
}
|
||
|
||
/** Acknowledges (destroys) event notifications that have been shown. */
|
||
async destroyCalendarEventNotifications(ids: string[]): Promise<void> {
|
||
if (ids.length === 0 || !this.supportsCalendars()) return;
|
||
const accountId = this.getCalendarsAccountId();
|
||
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
|
||
await this.request([
|
||
["CalendarEventNotification/set", { accountId, destroy: batch }, "0"],
|
||
], this.calendarUsing());
|
||
}
|
||
}
|
||
|
||
// ── ParticipantIdentity (draft-ietf-jmap-calendars §6) ─────────
|
||
|
||
/**
|
||
* The calendar addresses this user can organise events as, with the
|
||
* server's default flagged. Empty when the server has no calendars.
|
||
*/
|
||
async getParticipantIdentities(): Promise<CalendarParticipantIdentity[]> {
|
||
if (!this.supportsCalendars()) return [];
|
||
const accountId = this.getCalendarsAccountId();
|
||
const response = await this.request([
|
||
["ParticipantIdentity/get", { accountId }, "0"],
|
||
], this.calendarUsing());
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "ParticipantIdentity/get") {
|
||
throw new Error((result as { description?: string })?.description || "Failed to load participant identities");
|
||
}
|
||
return ((result as { list?: Array<Partial<CalendarParticipantIdentity> & { id: string }> }).list ?? []).map((i) => ({
|
||
id: i.id,
|
||
name: i.name ?? "",
|
||
calendarAddress: i.calendarAddress ?? "",
|
||
isDefault: i.isDefault === true,
|
||
}));
|
||
}
|
||
|
||
/** Makes `id` the default identity (ParticipantIdentity/set onSuccessSetIsDefault). */
|
||
async setDefaultParticipantIdentity(id: string): Promise<void> {
|
||
const accountId = this.getCalendarsAccountId();
|
||
const response = await this.request([
|
||
["ParticipantIdentity/set", { accountId, onSuccessSetIsDefault: id }, "0"],
|
||
], this.calendarUsing());
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "ParticipantIdentity/set") {
|
||
throw new Error((result as { description?: string })?.description || "Failed to set the default participant identity");
|
||
}
|
||
}
|
||
|
||
// ── Principal/getAvailability (free/busy) ──────────────────────
|
||
|
||
static readonly PRINCIPALS_AVAILABILITY_CAPABILITY = 'urn:ietf:params:jmap:principals:availability';
|
||
|
||
/** Whether the server can answer free/busy queries for its principals. */
|
||
supportsAvailability(): boolean {
|
||
return this.supportsPrincipals() && this.hasCapability(JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY);
|
||
}
|
||
|
||
/**
|
||
* The busy periods of a principal between `utcStart` and `utcEnd`
|
||
* (Principal/getAvailability). Event details are never requested: this
|
||
* only tells whether an attendee is free, not what they are doing.
|
||
*/
|
||
async getPrincipalAvailability(principalId: string, utcStart: Date, utcEnd: Date): Promise<BusyPeriod[]> {
|
||
if (!this.supportsAvailability()) return [];
|
||
const response = await this.request([
|
||
["Principal/getAvailability", {
|
||
accountId: this.accountId,
|
||
id: principalId,
|
||
utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||
utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||
showDetails: false,
|
||
}, "0"],
|
||
], [...this.principalsUsing(), JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY]);
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "Principal/getAvailability") {
|
||
throw new Error((result as { description?: string })?.description || "Failed to query availability");
|
||
}
|
||
return ((result as { list?: Array<{ utcStart: string; utcEnd: string; busyStatus?: BusyPeriod['busyStatus'] }> }).list ?? [])
|
||
.map((p) => ({ utcStart: p.utcStart, utcEnd: p.utcEnd, busyStatus: p.busyStatus ?? null }));
|
||
}
|
||
|
||
// ── ShareNotification (RFC 9670 §3) ────────────────────────────
|
||
|
||
/** Whether the server can report who shared what with this user. */
|
||
supportsShareNotifications(): boolean {
|
||
return this.supportsPrincipals();
|
||
}
|
||
|
||
/**
|
||
* Every pending share notification of the user's own account, oldest
|
||
* first. The list is small by construction: notifications are destroyed
|
||
* once they have been shown (see destroyShareNotifications).
|
||
*/
|
||
async getShareNotifications(): Promise<ShareNotification[]> {
|
||
if (!this.supportsShareNotifications()) return [];
|
||
const accountId = this.accountId;
|
||
const response = await this.request([
|
||
["ShareNotification/query", {
|
||
accountId,
|
||
sort: [{ property: "created", isAscending: true }],
|
||
}, "0"],
|
||
["ShareNotification/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "0", name: "ShareNotification/query", path: "/ids" },
|
||
}, "1"],
|
||
], this.principalsUsing());
|
||
const getResp = response.methodResponses?.find((r) => r[0] === "ShareNotification/get");
|
||
if (!getResp) {
|
||
const error = response.methodResponses?.find((r) => r[0] === "error")?.[1] as { description?: string } | undefined;
|
||
throw new Error(error?.description || "Failed to load share notifications");
|
||
}
|
||
return ((getResp[1] as { list?: ShareNotification[] }).list ?? []);
|
||
}
|
||
|
||
/** Acknowledges (destroys) share notifications that have been shown. */
|
||
async destroyShareNotifications(ids: string[]): Promise<void> {
|
||
if (ids.length === 0 || !this.supportsShareNotifications()) return;
|
||
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
|
||
await this.request([
|
||
["ShareNotification/set", { accountId: this.accountId, destroy: batch }, "0"],
|
||
], this.principalsUsing());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add, update, or remove a principal's rights on a calendar.
|
||
* Pass `rights: null` to revoke access.
|
||
*/
|
||
async setCalendarShare(
|
||
calendarId: string,
|
||
principalId: string,
|
||
rights: CalendarRights | null,
|
||
targetAccountId?: string,
|
||
): Promise<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const response = await this.request([
|
||
["Calendar/set", {
|
||
accountId,
|
||
update: { [calendarId]: { [`shareWith/${principalId}`]: rights } },
|
||
}, "0"],
|
||
], this.calendarUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[calendarId]) {
|
||
const err = result.notUpdated[calendarId];
|
||
throw new Error(err.description || "Failed to update calendar share");
|
||
}
|
||
if (!result?.updated || !(calendarId in result.updated)) {
|
||
throw new Error("Server did not confirm the share update");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add, update, or remove a principal's rights on an address book.
|
||
* Pass `rights: null` to revoke access.
|
||
*/
|
||
async setAddressBookShare(
|
||
addressBookId: string,
|
||
principalId: string,
|
||
rights: AddressBookRights | null,
|
||
targetAccountId?: string,
|
||
): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
const response = await this.request([
|
||
["AddressBook/set", {
|
||
accountId,
|
||
update: { [addressBookId]: { [`shareWith/${principalId}`]: rights } },
|
||
}, "0"],
|
||
], this.contactUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[addressBookId]) {
|
||
const err = result.notUpdated[addressBookId];
|
||
throw new Error(err.description || "Failed to update address book share");
|
||
}
|
||
if (!result?.updated || !(addressBookId in result.updated)) {
|
||
throw new Error("Server did not confirm the share update");
|
||
}
|
||
}
|
||
|
||
// ── Mailbox sharing (urn:ietf:params:jmap:mail:share) ─────────────
|
||
|
||
static readonly MAIL_SHARE_CAPABILITY = 'urn:ietf:params:jmap:mail:share';
|
||
|
||
/** Whether the account's server accepts `shareWith` on Mailbox objects. */
|
||
supportsMailboxSharing(accountId?: string): boolean {
|
||
return this.hasAccountCapability(JMAPClient.MAIL_SHARE_CAPABILITY, accountId || this.accountId);
|
||
}
|
||
|
||
/**
|
||
* The current `shareWith` map of one folder. Fetched on demand (the share
|
||
* dialog) rather than with every folder list: Stalwart omits shareWith from
|
||
* Mailbox/get unless it is requested, and it is only needed here.
|
||
*/
|
||
async getMailboxShareWith(mailboxId: string, accountId?: string): Promise<Record<string, MailboxRights> | null> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const response = await this.request([
|
||
["Mailbox/get", {
|
||
accountId: targetAccountId,
|
||
ids: [mailboxId],
|
||
properties: ["id", "shareWith"],
|
||
}, "0"],
|
||
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY]);
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "Mailbox/get") {
|
||
throw new Error((result as { description?: string })?.description || "Failed to load folder sharing");
|
||
}
|
||
const mailbox = ((result as { list?: Array<{ id: string; shareWith?: Record<string, MailboxRights> | null }> }).list ?? [])
|
||
.find((mb) => mb.id === mailboxId);
|
||
if (!mailbox) {
|
||
throw new Error("Folder not found");
|
||
}
|
||
return mailbox.shareWith ?? null;
|
||
}
|
||
|
||
/**
|
||
* Grants (`rights`) or revokes (`null`) a principal's access to a folder
|
||
* via a `shareWith/{principalId}` patch, like the calendar / address book
|
||
* variants.
|
||
*/
|
||
async setMailboxShare(
|
||
mailboxId: string,
|
||
principalId: string,
|
||
rights: MailboxRights | null,
|
||
accountId?: string,
|
||
): Promise<void> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
const response = await this.request([
|
||
["Mailbox/set", {
|
||
accountId: targetAccountId,
|
||
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
|
||
}, "0"],
|
||
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", JMAPClient.MAIL_SHARE_CAPABILITY]);
|
||
|
||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||
if (method !== "Mailbox/set") {
|
||
throw new Error((result as { description?: string })?.description || "Failed to update folder share");
|
||
}
|
||
const setResult = result as { updated?: Record<string, unknown>; notUpdated?: Record<string, { description?: string }> };
|
||
if (setResult.notUpdated?.[mailboxId]) {
|
||
throw new Error(setResult.notUpdated[mailboxId].description || "Failed to update folder share");
|
||
}
|
||
if (!setResult.updated || !(mailboxId in setResult.updated)) {
|
||
throw new Error("Server did not confirm the share update");
|
||
}
|
||
}
|
||
|
||
private async fetchPaginatedContacts(
|
||
accountId: string,
|
||
filter?: Record<string, unknown>,
|
||
): Promise<ContactCard[]> {
|
||
const batchSize = this.getMaxObjectsInGet();
|
||
const allIds: string[] = [];
|
||
let position = 0;
|
||
|
||
// Paginate ContactCard/query to collect all IDs
|
||
for (;;) {
|
||
const queryArgs: Record<string, unknown> = { accountId, position, limit: batchSize };
|
||
if (filter) {
|
||
queryArgs.filter = filter;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["ContactCard/query", queryArgs, "q"],
|
||
], this.contactUsing());
|
||
|
||
const queryResult = response.methodResponses?.[0];
|
||
if (queryResult?.[0] !== "ContactCard/query") break;
|
||
|
||
const ids: string[] = queryResult[1].ids || [];
|
||
allIds.push(...ids);
|
||
|
||
const total: number = queryResult[1].total ?? -1;
|
||
if (ids.length < batchSize || (total > 0 && allIds.length >= total)) {
|
||
break;
|
||
}
|
||
position += ids.length;
|
||
}
|
||
|
||
if (allIds.length === 0) return [];
|
||
|
||
// Batch ContactCard/get to respect maxObjectsInGet
|
||
const allContacts: ContactCard[] = [];
|
||
for (let i = 0; i < allIds.length; i += batchSize) {
|
||
const chunk = allIds.slice(i, i + batchSize);
|
||
const response = await this.request([
|
||
["ContactCard/get", { accountId, ids: chunk }, "g"],
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
|
||
allContacts.push(...list);
|
||
}
|
||
}
|
||
|
||
return allContacts;
|
||
}
|
||
|
||
async getContacts(addressBookId?: string, options?: { throwOnError?: boolean }): Promise<ContactCard[]> {
|
||
try {
|
||
const accountId = this.getContactsAccountId();
|
||
const filter = addressBookId ? { inAddressBook: addressBookId } : undefined;
|
||
return await this.fetchPaginatedContacts(accountId, filter);
|
||
} catch (error) {
|
||
console.error('Failed to get contacts:', error);
|
||
if (options?.throwOnError) throw error;
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async getAllContacts(): Promise<ContactCard[]> {
|
||
try {
|
||
const allContacts: ContactCard[] = [];
|
||
const primaryId = this.getContactsAccountId();
|
||
const accountIds = this.getContactCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const rawContacts = await this.fetchPaginatedContacts(accountId);
|
||
const contacts = rawContacts.map((contact) => ({
|
||
...contact,
|
||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||
originalId: contact.id,
|
||
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
||
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
||
) : contact.addressBookIds),
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}));
|
||
allContacts.push(...contacts);
|
||
} catch (error) {
|
||
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allContacts;
|
||
} catch (error) {
|
||
console.error('Failed to fetch all contacts:', error);
|
||
return this.getContacts();
|
||
}
|
||
}
|
||
|
||
async getContact(contactId: string, accountId?: string): Promise<ContactCard | null> {
|
||
try {
|
||
const targetAccountId = accountId || this.getContactsAccountId();
|
||
const response = await this.request([
|
||
["ContactCard/get", {
|
||
accountId: targetAccountId,
|
||
ids: [contactId],
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
|
||
const list = response.methodResponses[0][1].list || [];
|
||
return list[0] || null;
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
console.error('Failed to get contact:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
let addressBookIds = contact.addressBookIds;
|
||
if (!addressBookIds || Object.keys(addressBookIds).length === 0) {
|
||
const books = await this.getAddressBooks();
|
||
const defaultBook = books.find(b => b.isDefault) || books[0];
|
||
if (defaultBook) {
|
||
addressBookIds = { [defaultBook.id]: true };
|
||
}
|
||
}
|
||
|
||
// Strip shared-only fields before sending to JMAP
|
||
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...contactData } = contact as ContactCard;
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
create: {
|
||
"new-contact": {
|
||
...contactData,
|
||
// Stalwart stores the card without one if omitted (#644)
|
||
uid: contactData.uid || `urn:uuid:${generateUUID()}`,
|
||
addressBookIds,
|
||
}
|
||
}
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notCreated?.["new-contact"]) {
|
||
const error = result.notCreated["new-contact"];
|
||
throw new Error(error.description || "Failed to create contact");
|
||
}
|
||
|
||
const createdId = result.created?.["new-contact"]?.id;
|
||
if (createdId) {
|
||
const created = await this.getContact(createdId, accountId);
|
||
if (created) return created;
|
||
}
|
||
}
|
||
|
||
throw new Error("Failed to create contact");
|
||
}
|
||
|
||
async updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
|
||
// Strip shared-only fields before sending to JMAP
|
||
const { originalId: _oid, accountId: _aid, accountName: _an, isShared: _is, ...cleanUpdates } = updates as ContactCard;
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
update: {
|
||
[contactId]: cleanUpdates
|
||
}
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notUpdated?.[contactId]) {
|
||
const error = result.notUpdated[contactId];
|
||
throw new Error(error.description || "Failed to update contact");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to update contact");
|
||
}
|
||
|
||
async deleteContact(contactId: string, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getContactsAccountId();
|
||
|
||
const response = await this.request([
|
||
["ContactCard/set", {
|
||
accountId,
|
||
destroy: [contactId]
|
||
}, "0"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "ContactCard/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notDestroyed?.[contactId]) {
|
||
const error = result.notDestroyed[contactId];
|
||
throw new Error(error.description || "Failed to delete contact");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to delete contact");
|
||
}
|
||
|
||
async searchContacts(query: string): Promise<ContactCard[]> {
|
||
try {
|
||
const allResults: ContactCard[] = [];
|
||
const primaryId = this.getContactsAccountId();
|
||
const accountIds = this.getContactCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const response = await this.request([
|
||
["ContactCard/query", {
|
||
accountId,
|
||
filter: { text: query },
|
||
limit: 50,
|
||
}, "0"],
|
||
["ContactCard/get", {
|
||
accountId,
|
||
"#ids": { resultOf: "0", name: "ContactCard/query", path: "/ids" },
|
||
}, "1"]
|
||
], this.contactUsing());
|
||
|
||
if (response.methodResponses?.[1]?.[0] === "ContactCard/get") {
|
||
const rawContacts = (response.methodResponses[1][1].list || []) as ContactCard[];
|
||
const contacts = rawContacts.map((contact) => ({
|
||
...contact,
|
||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||
originalId: contact.id,
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}));
|
||
allResults.push(...contacts);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to search contacts for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allResults;
|
||
} catch (error) {
|
||
console.error('Failed to search contacts:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Calendar/get for one account. The session's first one on the primary
|
||
* calendar account also carries the synthetic-id probe (see
|
||
* supportsSyntheticCalendarIds), so the range query that follows needs no
|
||
* round trip of its own for it.
|
||
*/
|
||
private requestCalendars(accountId: string): Promise<JMAPResponse> {
|
||
const methodCalls: JMAPMethodCall[] = [
|
||
["Calendar/get", { accountId, properties: CALENDAR_PROPERTIES }, "0"],
|
||
];
|
||
const carriesProbe = !this.syntheticCalendarIdSupport && accountId === this.getCalendarsAccountId();
|
||
if (carriesProbe) methodCalls.push(this.syntheticIdProbeCall(accountId));
|
||
const pending = this.request(methodCalls, this.calendarUsing());
|
||
if (carriesProbe) this.rememberSyntheticIdProbe(pending);
|
||
return pending;
|
||
}
|
||
|
||
async getCalendars(): Promise<Calendar[]> {
|
||
try {
|
||
const accountId = this.getCalendarsAccountId();
|
||
const response = await this.requestCalendars(accountId);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||
const calendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
||
const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, calendars.map((c) => c.id));
|
||
return calendars.map((cal) => ({ ...cal, isTasksOnly: tasksOnly.has(cal.id) }));
|
||
}
|
||
return [];
|
||
} catch (error) {
|
||
console.error('Failed to get calendars:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ids of the given calendars that hold only tasks and no events, so the event
|
||
* calendar UI can hide them. Stalwart's Calendar/get exposes no
|
||
* supported-component set, so this is derived by scanning the objects.
|
||
*
|
||
* Cheap in the common case and safe on the edges: each page is classified as
|
||
* it arrives and, as soon as EVERY calendar has been seen to contain an event,
|
||
* the scan stops (a normal event account exits after one page). It only marks
|
||
* a calendar tasks-only after scanning ALL of that account's objects; if the
|
||
* account has more than the scan cap, it fails OPEN (marks nothing) rather
|
||
* than risk hiding a calendar whose events sit past the cap. Any error also
|
||
* fails open. Empty calendars are never marked (they must stay visible).
|
||
*/
|
||
private async getTasksOnlyCalendarIds(accountId: string, rawCalendarIds: string[]): Promise<Set<string>> {
|
||
if (rawCalendarIds.length === 0) return new Set();
|
||
|
||
const QUERY_PAGE = 500;
|
||
const MAX_SCAN = 5000; // safety bound; beyond this we fail open
|
||
const PROPERTIES = ['id', '@type', 'due', 'progress', 'percentComplete', 'calendarIds'];
|
||
const timeZone = getUserTimeZone();
|
||
const objects: ScannedCalendarObject[] = [];
|
||
const remaining = new Set(rawCalendarIds); // calendars not yet known to hold an event
|
||
|
||
try {
|
||
let exhausted = false;
|
||
for (let position = 0; position < MAX_SCAN;) {
|
||
const queryArgs: Record<string, unknown> = { accountId, limit: QUERY_PAGE, position };
|
||
if (timeZone) queryArgs.timeZone = timeZone;
|
||
const qResp = await this.request([["CalendarEvent/query", queryArgs, "0"]], this.calendarUsing());
|
||
if (qResp.methodResponses?.[0]?.[0] === "error") return new Set(); // fail open
|
||
const pageIds: string[] = qResp.methodResponses?.[0]?.[1]?.ids || [];
|
||
if (pageIds.length === 0) { exhausted = true; break; }
|
||
|
||
const getResp = await this.request([
|
||
["CalendarEvent/get", { accountId, ids: pageIds, properties: PROPERTIES, ...(timeZone ? { timeZone } : {}) }, "0"]
|
||
], this.calendarUsing());
|
||
const list = (getResp.methodResponses?.[0]?.[1]?.list || []) as ScannedCalendarObject[];
|
||
for (const obj of list) {
|
||
objects.push(obj);
|
||
if (!isTaskLikeObject(obj) && obj.calendarIds) {
|
||
for (const id of Object.keys(obj.calendarIds)) remaining.delete(id);
|
||
}
|
||
}
|
||
// Every calendar has at least one event -> none can be tasks-only.
|
||
if (remaining.size === 0) return new Set();
|
||
if (pageIds.length < QUERY_PAGE) { exhausted = true; break; }
|
||
position += pageIds.length;
|
||
}
|
||
if (!exhausted) return new Set(); // hit the cap without finishing -> hide nothing
|
||
return findTasksOnlyCalendarIds(objects, rawCalendarIds);
|
||
} catch {
|
||
return new Set(); // fail open
|
||
}
|
||
}
|
||
|
||
async getAllCalendars(): Promise<Calendar[]> {
|
||
try {
|
||
const allCalendars: Calendar[] = [];
|
||
const primaryId = this.getCalendarsAccountId();
|
||
const accountIds = this.getCalendarCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const response = await this.requestCalendars(accountId);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||
const rawCalendars = (response.methodResponses[0][1].list || []) as Calendar[];
|
||
const tasksOnly = await this.getTasksOnlyCalendarIds(accountId, rawCalendars.map((c) => c.id));
|
||
const calendars = rawCalendars.map((cal) => ({
|
||
...cal,
|
||
id: isPrimary ? cal.id : `${accountId}:${cal.id}`,
|
||
originalId: cal.id,
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
isTasksOnly: tasksOnly.has(cal.id),
|
||
}));
|
||
allCalendars.push(...calendars);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to fetch calendars for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allCalendars;
|
||
} catch (error) {
|
||
console.error('Failed to fetch all calendars:', error);
|
||
return this.getCalendars();
|
||
}
|
||
}
|
||
|
||
async createCalendar(calendar: Partial<Calendar>, targetAccountId?: string, options?: CreateCalendarOptions): Promise<Calendar> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
// Stalwart's Calendar/set cannot express the CalDAV
|
||
// supported-calendar-component-set, and the server only accepts that
|
||
// property while the collection is being created, so calendars in the
|
||
// user's own account are made with MKCALENDAR through the WebDAV proxy and
|
||
// finished off over JMAP. If that path is unavailable (older server, proxy
|
||
// unreachable, shared account) we fall back to a plain Calendar/set, which
|
||
// yields the previous "every component type" calendar (#760).
|
||
if (accountId === this.getAccountId()) {
|
||
const created = await this.createCalendarViaCalDav(calendar, accountId, options?.components);
|
||
if (created) return created;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["Calendar/set", {
|
||
accountId,
|
||
create: {
|
||
"new-calendar": calendar
|
||
}
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notCreated?.["new-calendar"]) {
|
||
const error = result.notCreated["new-calendar"];
|
||
throw new Error(error.description || "Failed to create calendar");
|
||
}
|
||
|
||
const createdId = result.created?.["new-calendar"]?.id;
|
||
if (createdId) {
|
||
const created = await this.getCalendarById(accountId, createdId);
|
||
if (created) return created;
|
||
}
|
||
}
|
||
|
||
throw new Error("Failed to create calendar");
|
||
}
|
||
|
||
private async getCalendarById(accountId: string, calendarId: string): Promise<Calendar | null> {
|
||
const response = await this.request([
|
||
["Calendar/get", { accountId, ids: [calendarId], properties: CALENDAR_PROPERTIES }, "0"]
|
||
], this.calendarUsing());
|
||
if (response.methodResponses?.[0]?.[0] === "Calendar/get") {
|
||
const list = response.methodResponses[0][1].list || [];
|
||
if (list[0]) return list[0] as Calendar;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private async listAccountCalendars(accountId: string, properties: readonly string[]): Promise<Calendar[]> {
|
||
const response = await this.request([
|
||
["Calendar/get", { accountId, ids: null, properties }, "0"]
|
||
], this.calendarUsing());
|
||
if (response.methodResponses?.[0]?.[0] !== "Calendar/get") {
|
||
throw new Error("Failed to list calendars");
|
||
}
|
||
return (response.methodResponses[0][1].list || []) as Calendar[];
|
||
}
|
||
|
||
/**
|
||
* MKCALENDAR the collection (pinning its component set), find the JMAP id
|
||
* Stalwart gave it, then apply the remaining properties with Calendar/set.
|
||
* Returns null when the CalDAV step is unavailable so the caller can fall
|
||
* back to a JMAP-only create; throws if the collection was created but
|
||
* could not be completed (a retry over JMAP would leave a duplicate).
|
||
*/
|
||
private async createCalendarViaCalDav(
|
||
calendar: Partial<Calendar>,
|
||
accountId: string,
|
||
components?: CalendarComponentType[],
|
||
): Promise<Calendar | null> {
|
||
const displayName = typeof calendar.name === 'string' ? calendar.name.trim() : '';
|
||
if (!displayName) return null;
|
||
const componentSet = components && components.length > 0
|
||
? Array.from(new Set(components))
|
||
: DEFAULT_CALENDAR_COMPONENTS;
|
||
|
||
// The DAV response carries no JMAP id, so snapshot the ids beforehand and
|
||
// pick out the newcomer afterwards.
|
||
let before: Set<string>;
|
||
try {
|
||
before = new Set((await this.listAccountCalendars(accountId, ["id"])).map((c) => c.id));
|
||
} catch {
|
||
return null;
|
||
}
|
||
|
||
let created: boolean;
|
||
try {
|
||
created = await mkCalendarCollection({
|
||
collectionName: newCalendarCollectionName(),
|
||
displayName,
|
||
components: componentSet,
|
||
});
|
||
} catch (error) {
|
||
debug.log('calendar', 'MKCALENDAR unavailable, falling back to Calendar/set', error);
|
||
return null;
|
||
}
|
||
if (!created) return null;
|
||
|
||
const fresh = (await this.listAccountCalendars(accountId, CALENDAR_PROPERTIES)).filter((c) => !before.has(c.id));
|
||
const found = fresh.find((c) => c.name === displayName) ?? fresh[0];
|
||
if (!found) throw new Error("Calendar was created but could not be found");
|
||
|
||
// The name went in with MKCALENDAR; colour, visibility etc. go over JMAP.
|
||
const updates: Record<string, unknown> = {};
|
||
for (const [key, value] of Object.entries(calendar as Record<string, unknown>)) {
|
||
if (key !== 'name' && value !== undefined && CALENDAR_SETTABLE_PROPERTIES.has(key)) {
|
||
updates[key] = value;
|
||
}
|
||
}
|
||
if (Object.keys(updates).length === 0) return found;
|
||
|
||
await this.updateCalendar(found.id, updates as Partial<Calendar>, accountId);
|
||
return (await this.getCalendarById(accountId, found.id)) ?? { ...found, ...updates } as Calendar;
|
||
}
|
||
|
||
async updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
// Stalwart rejects the whole update with invalidProperties if any key is
|
||
// not settable (e.g. id, isDefault, myRights, or client-only fields), so
|
||
// only forward the properties its Calendar/set actually accepts. Keys
|
||
// containing '/' are JSON-pointer patches; keep those whose root segment
|
||
// is settable (shareWith/..., defaultAlertsWithTime/...).
|
||
const cleanUpdates: Record<string, unknown> = {};
|
||
for (const [key, value] of Object.entries(updates as Record<string, unknown>)) {
|
||
const root = key.split('/', 1)[0];
|
||
if (CALENDAR_SETTABLE_PROPERTIES.has(root)) {
|
||
cleanUpdates[key] = value;
|
||
}
|
||
}
|
||
|
||
const response = await this.request([
|
||
["Calendar/set", {
|
||
accountId,
|
||
update: {
|
||
[calendarId]: cleanUpdates
|
||
}
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notUpdated?.[calendarId]) {
|
||
const error = result.notUpdated[calendarId];
|
||
throw new Error(error.description || "Failed to update calendar");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to update calendar");
|
||
}
|
||
|
||
/**
|
||
* Mark a calendar as the account default. `isDefault` is read-only in
|
||
* Stalwart's Calendar/set - the default is changed via the
|
||
* `onSuccessSetIsDefault` request argument instead.
|
||
*/
|
||
async setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
const response = await this.request([
|
||
["Calendar/set", {
|
||
accountId,
|
||
onSuccessSetIsDefault: calendarId
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
const methodName = response.methodResponses?.[0]?.[0];
|
||
if (methodName === "error") {
|
||
const error = response.methodResponses?.[0]?.[1];
|
||
throw new Error(error?.description || error?.type || "Failed to set default calendar");
|
||
}
|
||
if (methodName !== "Calendar/set") {
|
||
throw new Error("Failed to set default calendar");
|
||
}
|
||
}
|
||
|
||
async deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
const response = await this.request([
|
||
["Calendar/set", {
|
||
accountId,
|
||
destroy: [calendarId],
|
||
onDestroyRemoveEvents: true
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "Calendar/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notDestroyed?.[calendarId]) {
|
||
const error = result.notDestroyed[calendarId];
|
||
throw new Error(error.description || "Failed to delete calendar");
|
||
}
|
||
return;
|
||
}
|
||
|
||
throw new Error("Failed to delete calendar");
|
||
}
|
||
|
||
async getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||
const timeZone = getUserTimeZone();
|
||
|
||
const queryArgs: Record<string, unknown> = { accountId, limit: 1000 };
|
||
if (timeZone) {
|
||
queryArgs.timeZone = timeZone;
|
||
}
|
||
if (calendarIds && calendarIds.length > 0) {
|
||
queryArgs.filter = buildInCalendarFilter(calendarIds);
|
||
}
|
||
|
||
// First, query to get all IDs
|
||
const queryResponse = await this.request([
|
||
["CalendarEvent/query", queryArgs, "0"],
|
||
], this.calendarUsing());
|
||
|
||
// Check for JMAP method-level errors
|
||
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||
const error = queryResponse.methodResponses[0][1];
|
||
throw new Error(error?.description || error?.type || "CalendarEvent/query failed");
|
||
}
|
||
|
||
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||
if (ids.length === 0) return [];
|
||
|
||
// Batch the /get calls to stay within server max-objects limit
|
||
const allEvents: CalendarEvent[] = [];
|
||
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||
const getResponse = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||
ids: batchIds,
|
||
...(timeZone ? { timeZone } : {}),
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
|
||
allEvents.push(...events);
|
||
}
|
||
}
|
||
|
||
return allEvents
|
||
.filter((event) => !isTaskObject(event))
|
||
.map((event) => normalizeCalendarEventLike(event));
|
||
}
|
||
|
||
// Shared accounts the server rejected calendar access for - probed once,
|
||
// then skipped for the rest of the session (see getCalendarCapableAccountIds
|
||
// for why the fan-out has to probe on suspicion).
|
||
private calendarAccessDenied = new Set<string>();
|
||
|
||
// Result of the one-off synthetic-id capability probe, shared by every
|
||
// range query on this client (see supportsSyntheticCalendarIds).
|
||
private syntheticCalendarIdSupport: Promise<boolean> | null = null;
|
||
|
||
/**
|
||
* Whether CalendarEvent/set accepts the synthetic ids that
|
||
* CalendarEvent/query?expandRecurrences=true hands out (Stalwart >= 0.16.20).
|
||
*
|
||
* Probed once per client with a side-effect-free update of a synthetic id
|
||
* whose event cannot exist (see SYNTHETIC_ID_PROBE): a server that still
|
||
* rejects synthetic ids answers `invalidProperties` before looking the
|
||
* event up, one that supports them answers `notFound`. Anything else
|
||
* (transport error, non-Stalwart server) counts as unsupported so the
|
||
* client-side expansion keeps being used. The session's first Calendar/get
|
||
* carries the probe along (getCalendars), so the usual calendar start-up
|
||
* pays no extra round trip; a range query that comes first probes itself.
|
||
*/
|
||
async supportsSyntheticCalendarIds(): Promise<boolean> {
|
||
if (!this.syntheticCalendarIdSupport) {
|
||
const accountId = this.getCalendarsAccountId();
|
||
this.rememberSyntheticIdProbe(
|
||
this.request([this.syntheticIdProbeCall(accountId)], this.calendarUsing()),
|
||
);
|
||
}
|
||
return this.syntheticCalendarIdSupport!;
|
||
}
|
||
|
||
private static readonly SYNTHETIC_ID_PROBE_CALL_ID = 'synthetic-id-probe';
|
||
|
||
private syntheticIdProbeCall(accountId: string): JMAPMethodCall {
|
||
return ["CalendarEvent/set", { accountId, update: { [SYNTHETIC_ID_PROBE]: {} } }, JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID];
|
||
}
|
||
|
||
/** Derive (and cache) the probe verdict from a response that carried the probe call. */
|
||
private rememberSyntheticIdProbe(pending: Promise<JMAPResponse>): void {
|
||
this.syntheticCalendarIdSupport = pending.then(
|
||
(response) => {
|
||
const entry = response.methodResponses?.find((r) => r[2] === JMAPClient.SYNTHETIC_ID_PROBE_CALL_ID);
|
||
if (!entry || entry[0] !== "CalendarEvent/set") return false;
|
||
const failure = (entry[1] as { notUpdated?: Record<string, { type?: string }> } | undefined)?.notUpdated?.[SYNTHETIC_ID_PROBE];
|
||
const supported = failure?.type === 'notFound';
|
||
debug.log('calendar', 'Synthetic calendar id probe', { supported, failure: failure ?? null });
|
||
return supported;
|
||
},
|
||
(error) => {
|
||
debug.log('calendar', 'Synthetic calendar id probe failed; using client-side recurrence expansion', error);
|
||
return false;
|
||
},
|
||
);
|
||
}
|
||
|
||
async queryAllCalendarEvents(
|
||
filter: CalendarEventFilter,
|
||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||
limit?: number
|
||
): Promise<CalendarEvent[]> {
|
||
try {
|
||
const allEvents: CalendarEvent[] = [];
|
||
const primaryId = this.getCalendarsAccountId();
|
||
const accountIds = this.getCalendarCapableAccountIds();
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
|
||
const account = this.accounts[accountId];
|
||
|
||
try {
|
||
const events = await this.queryCalendarEvents(filter, sort, limit, accountId);
|
||
const mapped = events.map((event) => ({
|
||
...event,
|
||
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
||
originalId: event.id,
|
||
originalCalendarIds: event.calendarIds,
|
||
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
||
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||
),
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
}));
|
||
allEvents.push(...mapped);
|
||
} catch (error) {
|
||
console.error(`Failed to query calendar events for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return allEvents;
|
||
} catch (error) {
|
||
console.error('Failed to query all calendar events:', error);
|
||
return this.queryCalendarEvents(filter, sort, limit);
|
||
}
|
||
}
|
||
|
||
async queryCalendarEvents(
|
||
filter: CalendarEventFilter,
|
||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||
limit?: number,
|
||
targetAccountId?: string
|
||
): Promise<CalendarEvent[]> {
|
||
try {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const timeZone = getUserTimeZone();
|
||
|
||
const queryArgs: Record<string, unknown> = {
|
||
accountId,
|
||
filter,
|
||
limit: limit || 1000,
|
||
};
|
||
// Interpret the LocalDateTime after/before filter values in the user's
|
||
// time zone (Stalwart defaults to UTC, shifting range boundaries).
|
||
if (timeZone) {
|
||
queryArgs.timeZone = timeZone;
|
||
}
|
||
// Let the server expand recurring series into one synthetic id per
|
||
// occurrence when it also accepts those ids in CalendarEvent/set
|
||
// (Stalwart >= 0.16.20; the server needs both range bounds for it).
|
||
// Older servers keep the client-side expansion in
|
||
// lib/recurrence-expansion.ts, which runs on whatever comes back.
|
||
const expandRecurrences = Boolean(filter.after && filter.before)
|
||
&& await this.supportsSyntheticCalendarIds();
|
||
if (expandRecurrences) {
|
||
queryArgs.expandRecurrences = true;
|
||
}
|
||
if (sort) {
|
||
queryArgs.sort = sort;
|
||
}
|
||
|
||
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||
|
||
// First, query to get IDs
|
||
const queryResponse = await this.request([
|
||
["CalendarEvent/query", queryArgs, "0"],
|
||
], this.calendarUsing());
|
||
|
||
if (queryResponse.methodResponses?.[0]?.[0] === "error") {
|
||
const error = queryResponse.methodResponses[0][1];
|
||
// Keep the JMAP error type so the catch below can tell an expected
|
||
// access rejection apart from a genuine failure.
|
||
throw Object.assign(
|
||
new Error(error?.description || error?.type || "CalendarEvent/query failed"),
|
||
{ jmapErrorType: error?.type },
|
||
);
|
||
}
|
||
|
||
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
|
||
if (ids.length === 0) return [];
|
||
|
||
// Batch the /get calls to stay within server max-objects limit
|
||
const allEvents: CalendarEvent[] = [];
|
||
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||
const getResponse = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||
ids: batchIds,
|
||
...(timeZone ? { timeZone } : {}),
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||
const events = (getResponse.methodResponses[0][1].list || []) as CalendarEvent[];
|
||
allEvents.push(...events);
|
||
}
|
||
}
|
||
|
||
const normalized = allEvents
|
||
.filter((event) => !isTaskObject(event))
|
||
.map((event) => normalizeCalendarEventLike(event));
|
||
const filtered = expandRecurrences
|
||
? await this.hydrateExpandedOccurrences(normalized, accountId, timeZone)
|
||
: normalized;
|
||
|
||
const eventsWithParticipants = filtered.filter(e => e.participants && Object.keys(e.participants).length > 0);
|
||
debug.log('calendar', 'queryCalendarEvents participant summary', {
|
||
totalEvents: filtered.length,
|
||
eventsWithParticipants: eventsWithParticipants.length,
|
||
details: eventsWithParticipants.map(e => ({
|
||
id: e.id,
|
||
title: e.title,
|
||
participantCount: Object.keys(e.participants!).length,
|
||
participants: e.participants,
|
||
replyTo: e.replyTo,
|
||
})),
|
||
});
|
||
|
||
return filtered;
|
||
} catch (error) {
|
||
// The fan-out over shared accounts probes on suspicion (see
|
||
// getCalendarCapableAccountIds) and may hit accounts that grant no
|
||
// calendar access at all. Remember the rejection and go quiet instead
|
||
// of re-probing - and re-logging - on every range change.
|
||
const type = (error as { jmapErrorType?: string } | null)?.jmapErrorType;
|
||
const denied = type === 'forbidden' || type === 'accountNotFound' ||
|
||
/not have access/i.test(error instanceof Error ? error.message : '');
|
||
if (targetAccountId && denied) {
|
||
this.calendarAccessDenied.add(targetAccountId);
|
||
debug.log('calendar', `No calendar access to account ${targetAccountId} - skipping it from now on`);
|
||
return [];
|
||
}
|
||
console.error('Failed to query calendar events:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Server-expanded occurrences come back without their series' recurrence
|
||
* rule / overrides (and all-day ones without showWithoutTime). Fetch those
|
||
* from the base events in one batched get and merge them in, so an
|
||
* occurrence carries the same series context the client-side expansion
|
||
* used to copy from its master.
|
||
*/
|
||
private async hydrateExpandedOccurrences(
|
||
events: CalendarEvent[],
|
||
accountId: string,
|
||
timeZone?: string,
|
||
): Promise<CalendarEvent[]> {
|
||
const baseIds = Array.from(new Set(
|
||
events
|
||
.filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId)
|
||
.map((event) => event.baseEventId as string),
|
||
));
|
||
if (baseIds.length === 0) return events;
|
||
|
||
const bases = new Map<string, Partial<CalendarEvent>>();
|
||
for (const batch of batched(baseIds, this.getMaxObjectsInGet())) {
|
||
const response = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...RECURRENCE_BASE_PROPERTIES],
|
||
ids: batch,
|
||
...(timeZone ? { timeZone } : {}),
|
||
}, "0"],
|
||
], this.calendarUsing());
|
||
if (response.methodResponses?.[0]?.[0] !== "CalendarEvent/get") continue;
|
||
for (const base of (response.methodResponses[0][1].list || []) as Partial<CalendarEvent>[]) {
|
||
if (base.id) bases.set(base.id, normalizeCalendarEventLike(base));
|
||
}
|
||
}
|
||
return hydrateRecurrenceInstances(events, bases);
|
||
}
|
||
|
||
async getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null> {
|
||
try {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const timeZone = getUserTimeZone();
|
||
const response = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||
ids: [id],
|
||
...(timeZone ? { timeZone } : {}),
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||
const list = response.methodResponses[0][1].list || [];
|
||
return list[0] ? normalizeCalendarEventLike(list[0] as CalendarEvent) : null;
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
console.error('Failed to get calendar event:', error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
// Strip client-only shared fields before sending to JMAP
|
||
const { originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanEvent } = event as CalendarEvent;
|
||
cleanRecurrenceRules(cleanEvent as unknown as Record<string, unknown>);
|
||
|
||
debug.group('CalendarEvent/create', 'calendar');
|
||
debug.log('calendar', 'CalendarEvent/create outgoing payload', {
|
||
accountId,
|
||
sendSchedulingMessages,
|
||
eventKeys: Object.keys(cleanEvent),
|
||
hasParticipants: !!cleanEvent.participants,
|
||
participantCount: cleanEvent.participants ? Object.keys(cleanEvent.participants).length : 0,
|
||
participants: cleanEvent.participants || null,
|
||
replyTo: cleanEvent.replyTo || null,
|
||
});
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
accountId,
|
||
create: {
|
||
"new-event": cleanEvent
|
||
}
|
||
};
|
||
if (sendSchedulingMessages !== undefined) {
|
||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||
}
|
||
|
||
const response = await this.request([
|
||
["CalendarEvent/set", setArgs, "0"]
|
||
], this.calendarUsing());
|
||
|
||
debug.log('calendar', 'CalendarEvent/create raw set response', response.methodResponses?.[0]?.[1] || null);
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||
const result = response.methodResponses[0][1];
|
||
|
||
if (result.notCreated?.["new-event"]) {
|
||
const error = result.notCreated["new-event"];
|
||
debug.warn('calendar', 'CalendarEvent/create notCreated', error);
|
||
debug.warn('calendar', 'CalendarEvent/create invalid properties', error.properties);
|
||
debug.warn('calendar', 'CalendarEvent/create sent keys', Object.keys(cleanEvent));
|
||
debug.groupEnd();
|
||
throw new Error(error.description || "Failed to create calendar event");
|
||
}
|
||
|
||
const createdId = result.created?.["new-event"]?.id;
|
||
debug.log('calendar', 'CalendarEvent/create server acknowledged created id', {
|
||
createdId,
|
||
created: result.created?.['new-event'] || null,
|
||
});
|
||
|
||
if (createdId) {
|
||
const created = await this.getCalendarEvent(createdId, targetAccountId);
|
||
debug.log('calendar', 'CalendarEvent/create fetched created event', {
|
||
...getCalendarEventDebugSnapshot(created),
|
||
hasParticipants: !!created?.participants,
|
||
participantCount: created?.participants ? Object.keys(created.participants).length : 0,
|
||
participants: created?.participants || null,
|
||
replyTo: created?.replyTo || null,
|
||
});
|
||
|
||
if (created?.uid) {
|
||
try {
|
||
const verificationMatches = await this.queryCalendarEvents({ uid: created.uid }, undefined, undefined, targetAccountId);
|
||
debug.log('calendar', 'CalendarEvent/create verification query by uid', {
|
||
uid: created.uid,
|
||
matchCount: verificationMatches.length,
|
||
matches: verificationMatches.map((match) => getCalendarEventDebugSnapshot(match)),
|
||
});
|
||
} catch (verificationError) {
|
||
debug.warn('calendar', 'CalendarEvent/create verification query failed', verificationError);
|
||
}
|
||
}
|
||
|
||
if (created) {
|
||
debug.groupEnd();
|
||
return created;
|
||
}
|
||
|
||
debug.warn('calendar', 'CalendarEvent/create server returned created id but CalendarEvent/get returned null', {
|
||
createdId,
|
||
targetAccountId,
|
||
});
|
||
}
|
||
}
|
||
|
||
debug.groupEnd();
|
||
|
||
throw new Error("Failed to create calendar event");
|
||
}
|
||
|
||
/**
|
||
* Batch-create multiple calendar events in a single JMAP request.
|
||
* Returns arrays of successfully created events and failed creation keys.
|
||
*/
|
||
async batchCreateCalendarEvents(
|
||
events: Partial<CalendarEvent>[],
|
||
targetAccountId?: string,
|
||
): Promise<{ created: CalendarEvent[]; failed: string[] }> {
|
||
if (events.length === 0) return { created: [], failed: [] };
|
||
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
|
||
|
||
const createdIds: string[] = [];
|
||
const failed: string[] = [];
|
||
const indexed = events.map((event, index) => ({ event, index }));
|
||
|
||
for (const batch of batched(indexed, this.getMaxObjectsInSet())) {
|
||
// Build the create map: { "new-0": event0, "new-1": event1, ... }
|
||
const createMap: Record<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());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||
const result = response.methodResponses[0][1];
|
||
for (const { index } of batch) {
|
||
const key = `new-${index}`;
|
||
if (result.created?.[key]?.id) {
|
||
createdIds.push(result.created[key].id);
|
||
} else if (result.notCreated?.[key]) {
|
||
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
|
||
failed.push(key);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if (createdIds.length === 0) {
|
||
return { created: [], failed };
|
||
}
|
||
|
||
// Fetch the created events back for their server-assigned properties
|
||
const refetchTimeZone = getUserTimeZone();
|
||
const createdEvents: CalendarEvent[] = [];
|
||
|
||
for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) {
|
||
const getResponse = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...CALENDAR_EVENT_PROPERTIES],
|
||
ids: batchIds,
|
||
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||
const list = getResponse.methodResponses[0][1].list || [];
|
||
createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)));
|
||
}
|
||
}
|
||
|
||
debug.log('calendar', 'CalendarEvent/batchCreate result', {
|
||
requested: events.length,
|
||
created: createdEvents.length,
|
||
failed: failed.length,
|
||
});
|
||
|
||
return { created: createdEvents, failed };
|
||
}
|
||
|
||
async updateCalendarEvent(
|
||
eventId: string,
|
||
updates: Partial<CalendarEvent>,
|
||
sendSchedulingMessages?: boolean,
|
||
targetAccountId?: string
|
||
): Promise<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
// Strip client-only and server-immutable fields before sending to JMAP
|
||
const { id: _id, uid: _uid, '@type': _typ, created: _cr, updated: _up, sequence: _sq, isOrigin: _io, isDraft: _idr, originalId: _oi, baseEventId: _be, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...cleanUpdates } = updates as CalendarEvent;
|
||
cleanRecurrenceRules(cleanUpdates as unknown as Record<string, unknown>);
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
accountId,
|
||
update: {
|
||
[eventId]: cleanUpdates
|
||
}
|
||
};
|
||
if (sendSchedulingMessages !== undefined) {
|
||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||
}
|
||
|
||
debug.log('calendar', 'CalendarEvent/set update request', {
|
||
eventId,
|
||
accountId,
|
||
cleanUpdateKeys: Object.keys(cleanUpdates),
|
||
sendSchedulingMessages,
|
||
hasParticipants: !!cleanUpdates.participants,
|
||
participantCount: cleanUpdates.participants ? Object.keys(cleanUpdates.participants).length : 0,
|
||
participants: cleanUpdates.participants || null,
|
||
replyTo: (cleanUpdates as Record<string, unknown>).replyTo || null,
|
||
});
|
||
|
||
const response = await this.request([
|
||
["CalendarEvent/set", setArgs, "0"]
|
||
], this.calendarUsing());
|
||
|
||
const methodName = response.methodResponses?.[0]?.[0];
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
|
||
if (methodName === "error") {
|
||
const errorType = result?.type || 'unknown';
|
||
const errorDesc = result?.description || '';
|
||
debug.error('CalendarEvent/set update returned JMAP error', { type: errorType, description: errorDesc });
|
||
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
|
||
}
|
||
|
||
if (methodName === "CalendarEvent/set") {
|
||
if (result.notUpdated?.[eventId]) {
|
||
const error = result.notUpdated[eventId];
|
||
debug.error('CalendarEvent/set notUpdated', { eventId, error });
|
||
throw new Error(error.description || "Failed to update calendar event");
|
||
}
|
||
debug.log('calendar', 'CalendarEvent/set update full response', { methodName, result });
|
||
debug.log('calendar', 'CalendarEvent/set update success', { eventId, updated: result.updated ? Object.keys(result.updated) : null });
|
||
return;
|
||
}
|
||
|
||
debug.error('CalendarEvent/set update unexpected response', { methodName, result });
|
||
throw new Error("Failed to update calendar event");
|
||
}
|
||
|
||
async parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]> {
|
||
const response = await this.request([
|
||
["CalendarEvent/parse", {
|
||
accountId,
|
||
blobIds: [blobId],
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/parse") {
|
||
const result = response.methodResponses[0][1];
|
||
debug.log('calendar', '[CalendarEvent/parse] raw result:', result);
|
||
|
||
if (result.notParsable && result.notParsable.includes(blobId)) {
|
||
throw new Error("Invalid calendar file format");
|
||
}
|
||
|
||
if (result.notFound && result.notFound.includes(blobId)) {
|
||
throw new Error("Uploaded file not found");
|
||
}
|
||
|
||
const parsed = result.parsed?.[blobId];
|
||
if (parsed) {
|
||
return (Array.isArray(parsed) ? parsed : [parsed])
|
||
.map((event) => normalizeCalendarEventLike(event as Partial<CalendarEvent>));
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
throw new Error("Failed to parse calendar file");
|
||
}
|
||
|
||
async deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
|
||
const setArgs: Record<string, unknown> = {
|
||
accountId,
|
||
destroy: [eventId]
|
||
};
|
||
if (sendSchedulingMessages !== undefined) {
|
||
setArgs.sendSchedulingMessages = sendSchedulingMessages;
|
||
}
|
||
|
||
debug.log('calendar', 'CalendarEvent/set destroy request', { eventId, accountId, sendSchedulingMessages });
|
||
|
||
const response = await this.request([
|
||
["CalendarEvent/set", setArgs, "0"]
|
||
], this.calendarUsing());
|
||
|
||
const methodName = response.methodResponses?.[0]?.[0];
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
|
||
if (methodName === "error") {
|
||
const errorType = result?.type || 'unknown';
|
||
const errorDesc = result?.description || '';
|
||
debug.error('CalendarEvent/set destroy returned JMAP error', { type: errorType, description: errorDesc });
|
||
throw new Error(`JMAP error (${errorType}): ${errorDesc}`);
|
||
}
|
||
|
||
if (methodName === "CalendarEvent/set") {
|
||
if (result.notDestroyed?.[eventId]) {
|
||
const error = result.notDestroyed[eventId];
|
||
debug.error('CalendarEvent/set notDestroyed', { eventId, error });
|
||
throw new Error(error.description || "Failed to delete calendar event");
|
||
}
|
||
debug.log('calendar', 'CalendarEvent/set destroy success', { eventId, destroyed: result.destroyed });
|
||
return;
|
||
}
|
||
|
||
debug.error('CalendarEvent/set destroy unexpected response', { methodName, result });
|
||
throw new Error("Failed to delete calendar event");
|
||
}
|
||
|
||
async batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
|
||
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const destroyed: string[] = [];
|
||
const notDestroyed: string[] = [];
|
||
|
||
for (const batch of batched(eventIds, this.getMaxObjectsInSet())) {
|
||
const response = await this.request([
|
||
["CalendarEvent/set", { accountId, destroy: batch }, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
|
||
const result = response.methodResponses[0][1];
|
||
if (result.destroyed) destroyed.push(...result.destroyed);
|
||
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
|
||
}
|
||
}
|
||
|
||
return { destroyed, notDestroyed };
|
||
}
|
||
|
||
// ─── Calendar Tasks (JSCalendar Task objects via CalendarEvent endpoints) ───
|
||
|
||
/**
|
||
* Fetch all Task objects via the CalendarEvent endpoints.
|
||
*
|
||
* Stalwart has no `types` filter on CalendarEvent/query (it fails the whole
|
||
* query with `unsupportedFilter`), and the previous CalendarEvent/get
|
||
* ids:null fallback was capped at the server's maxObjectsInGet (500 by
|
||
* default), silently hiding tasks in larger accounts. Instead, page through
|
||
* CalendarEvent/query (which returns events *and* tasks), fetch in
|
||
* /get-sized batches, and detect tasks client-side.
|
||
*/
|
||
async getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
debug.group('CalendarTask/fetch', 'tasks');
|
||
debug.log('tasks', 'CalendarTask/fetch start', { accountId, calendarIds: calendarIds || 'all' });
|
||
|
||
try {
|
||
// Page through the query to collect all object ids.
|
||
const QUERY_PAGE = 1000;
|
||
const MAX_IDS = 50000; // safety bound
|
||
const timeZone = getUserTimeZone();
|
||
const ids: string[] = [];
|
||
for (let position = 0; position < MAX_IDS;) {
|
||
const queryArgs: Record<string, unknown> = { accountId, limit: QUERY_PAGE, position };
|
||
if (timeZone) {
|
||
queryArgs.timeZone = timeZone;
|
||
}
|
||
if (calendarIds && calendarIds.length > 0) {
|
||
queryArgs.filter = buildInCalendarFilter(calendarIds);
|
||
}
|
||
const response = await this.request([
|
||
["CalendarEvent/query", queryArgs, "0"],
|
||
], this.calendarUsing());
|
||
|
||
if (response.methodResponses?.[0]?.[0] === "error") {
|
||
const error = response.methodResponses[0][1];
|
||
debug.warn('tasks', 'CalendarTask/fetch query failed', error);
|
||
debug.groupEnd();
|
||
return [];
|
||
}
|
||
|
||
const pageIds: string[] = response.methodResponses?.[0]?.[1]?.ids || [];
|
||
ids.push(...pageIds);
|
||
if (pageIds.length < QUERY_PAGE) break;
|
||
position += pageIds.length;
|
||
}
|
||
|
||
debug.log('tasks', 'CalendarTask/fetch query returned', ids.length, 'object ids');
|
||
if (ids.length === 0) {
|
||
debug.groupEnd();
|
||
return [];
|
||
}
|
||
|
||
// Fetch the objects in batches that respect the server's /get limit.
|
||
const GET_BATCH_SIZE = this.getMaxObjectsInGet();
|
||
const allObjects: Record<string, unknown>[] = [];
|
||
for (let i = 0; i < ids.length; i += GET_BATCH_SIZE) {
|
||
const batchIds = ids.slice(i, i + GET_BATCH_SIZE);
|
||
const getResponse = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...CALENDAR_TASK_PROPERTIES],
|
||
ids: batchIds,
|
||
...(timeZone ? { timeZone } : {}),
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||
allObjects.push(...(getResponse.methodResponses[0][1].list || []));
|
||
}
|
||
}
|
||
|
||
const tasks: CalendarTask[] = [];
|
||
for (const obj of allObjects) {
|
||
const type = obj['@type'];
|
||
const isExplicitTask = typeof type === 'string' && type.toLowerCase() === 'task';
|
||
// CalDAV-created tasks (e.g. Thunderbird) may lack @type or have @type
|
||
// set to something other than 'Event'. Detect them by the presence of
|
||
// task-specific keys (due, progress, percentComplete), which RFC 8984 §5.2
|
||
// defines as Task-only - a VEVENT will never include them in the response.
|
||
// We check for key presence (even if null) because Stalwart may return null
|
||
// instead of the RFC defaults (e.g. progress default is "needs-action").
|
||
// @see https://www.rfc-editor.org/rfc/rfc8984#section-5.2
|
||
const hasTaskFields = ('due' in obj)
|
||
|| ('progress' in obj)
|
||
|| ('percentComplete' in obj);
|
||
const isCalDavTask = type !== 'Event' && hasTaskFields;
|
||
|
||
if (!isExplicitTask && !isCalDavTask) continue;
|
||
|
||
tasks.push({ ...obj, '@type': 'Task' as const } as CalendarTask);
|
||
}
|
||
|
||
debug.log('tasks', 'CalendarTask/fetch complete,', tasks.length, 'tasks of', allObjects.length, 'objects');
|
||
debug.groupEnd();
|
||
return tasks;
|
||
} catch (error) {
|
||
debug.error('CalendarTask/fetch failed', error);
|
||
debug.groupEnd();
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask> {
|
||
const accountId = targetAccountId || this.getCalendarsAccountId();
|
||
const { '@type': _type, ...taskData } = task;
|
||
const cleanTask = { ...taskData, '@type': 'Task' };
|
||
|
||
debug.group('CalendarTask/create', 'tasks');
|
||
debug.log('tasks', 'CalendarTask/create accountId', accountId);
|
||
debug.log('tasks', 'CalendarTask/create outgoing payload', cleanTask);
|
||
|
||
const response = await this.request([
|
||
["CalendarEvent/set", {
|
||
accountId,
|
||
sendSchedulingMessages: false,
|
||
create: { "new-task": cleanTask },
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
debug.log('tasks', 'CalendarTask/create raw set response', result);
|
||
|
||
if (result?.notCreated?.["new-task"]) {
|
||
const error = result.notCreated["new-task"];
|
||
debug.warn('tasks', 'CalendarTask/create REJECTED by server', error);
|
||
debug.groupEnd();
|
||
throw new Error(error.description || "Failed to create task");
|
||
}
|
||
|
||
const createdId = result?.created?.["new-task"]?.id;
|
||
const serverCreated = result?.created?.["new-task"];
|
||
debug.log('tasks', 'CalendarTask/create server acknowledged', { createdId, serverCreated });
|
||
|
||
if (!createdId) {
|
||
debug.warn('tasks', 'CalendarTask/create no id in server response');
|
||
debug.groupEnd();
|
||
throw new Error("Failed to create task - no id returned");
|
||
}
|
||
|
||
// Fetch back with task-specific properties
|
||
debug.log('calendar', 'CalendarTask/create re-fetching with task properties', { createdId, properties: [...CALENDAR_TASK_PROPERTIES] });
|
||
const refetchTimeZone = getUserTimeZone();
|
||
const getResponse = await this.request([
|
||
["CalendarEvent/get", {
|
||
accountId,
|
||
properties: [...CALENDAR_TASK_PROPERTIES],
|
||
ids: [createdId],
|
||
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
|
||
}, "0"]
|
||
], this.calendarUsing());
|
||
|
||
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
|
||
const list = getResponse.methodResponses[0][1].list || [];
|
||
const notFound = getResponse.methodResponses[0][1].notFound || [];
|
||
debug.log('calendar', 'CalendarTask/create get response', { found: list.length, notFound });
|
||
if (list[0]) {
|
||
const created = { ...list[0], '@type': 'Task' as const } as CalendarTask;
|
||
debug.log('tasks', 'CalendarTask/create final task object', {
|
||
id: created.id,
|
||
uid: created.uid,
|
||
'@type': created['@type'],
|
||
title: created.title,
|
||
due: created.due,
|
||
start: created.start,
|
||
progress: created.progress,
|
||
showWithoutTime: created.showWithoutTime,
|
||
calendarIds: created.calendarIds,
|
||
});
|
||
debug.groupEnd();
|
||
return created;
|
||
}
|
||
}
|
||
|
||
debug.warn('tasks', 'CalendarTask/create re-fetch returned nothing for id', createdId);
|
||
debug.groupEnd();
|
||
throw new Error("Failed to fetch created task");
|
||
}
|
||
|
||
async updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void> {
|
||
await this.updateCalendarEvent(taskId, updates as unknown as Partial<CalendarEvent>, false, targetAccountId);
|
||
}
|
||
|
||
async deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void> {
|
||
await this.deleteCalendarEvent(taskId, false, targetAccountId);
|
||
}
|
||
|
||
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
|
||
|
||
supportsFiles(accountId?: string): boolean {
|
||
// Gate on the ACCOUNT capability, not the server-wide session capability.
|
||
// A server can advertise urn:ietf:params:jmap:filenode while a specific
|
||
// account has its jmap-file-node-* permissions revoked, in which case the
|
||
// capability is absent from that account's accountCapabilities and every
|
||
// FileNode action fails with an authorization error (#563). Mirror
|
||
// getFilesCapableAccountIds(): non-personal (shared/group) accounts don't
|
||
// always advertise per-account, so treat those as capable.
|
||
const id = accountId || this.accountId;
|
||
const account = this.accounts[id];
|
||
if (!account) return false;
|
||
return !!account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal;
|
||
}
|
||
|
||
async probeFileNodeSupport(): Promise<boolean> {
|
||
// Some servers support FileNode without advertising a specific capability.
|
||
// Try a minimal FileNode/query to detect support at runtime.
|
||
if (this.supportsFiles()) return true;
|
||
// If the server advertises FileNode server-wide but this account's
|
||
// accountCapabilities omits it, that's an explicit per-account denial (#563)
|
||
// - don't probe (the probe would only confirm the revoked account can't use
|
||
// it, or worse mislead). Only fall through for servers that don't advertise
|
||
// the capability at all.
|
||
if (this.hasCapability("urn:ietf:params:jmap:filenode")) return false;
|
||
if (!this.apiUrl) return false;
|
||
try {
|
||
const accountId = this.getFilesAccountId();
|
||
const response = await this.authenticatedFetch(this.apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
using: ["urn:ietf:params:jmap:core"],
|
||
methodCalls: [["FileNode/query", { accountId, filter: {}, limit: 1 }, "probe0"]],
|
||
}),
|
||
});
|
||
if (!response.ok) return false;
|
||
const data = await response.json();
|
||
const result = data.methodResponses?.[0];
|
||
return result && result[0] === "FileNode/query";
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
getFilesAccountId(): string {
|
||
const filesAccount = this.session?.primaryAccounts?.["urn:ietf:params:jmap:filenode"];
|
||
return filesAccount || this.accountId;
|
||
}
|
||
|
||
private fileUsing(): string[] {
|
||
const using = ["urn:ietf:params:jmap:core"];
|
||
if (this.hasCapability("urn:ietf:params:jmap:filenode")) {
|
||
using.push("urn:ietf:params:jmap:filenode");
|
||
}
|
||
// RFC 9670 places principals:owner in accountCapabilities rather than
|
||
// the session capabilities, so this only fires on servers that also
|
||
// advertise it in the session.
|
||
if (this.hasCapability("urn:ietf:params:jmap:principals:owner")) {
|
||
using.push("urn:ietf:params:jmap:principals:owner");
|
||
}
|
||
return using;
|
||
}
|
||
|
||
private static FILE_NODE_PROPERTIES = [
|
||
"id", "parentId", "name", "type", "blobId", "size", "created", "modified",
|
||
// Stalwart omits shareWith/myRights from FileNode/get unless requested
|
||
// explicitly, so the share dialog and indicators can't see existing
|
||
// shares without naming them here (same as CALENDAR_PROPERTIES).
|
||
"shareWith", "myRights",
|
||
];
|
||
|
||
// Accounts (primary + shared/group) that can hold FileNodes. Mirrors
|
||
// getCalendarCapableAccountIds(): includes any non-primary account that
|
||
// advertises the filenode capability or is a non-personal (shared/group)
|
||
// account, since Stalwart doesn't always advertise capabilities on those.
|
||
private getFilesCapableAccountIds(): string[] {
|
||
const primaryId = this.getFilesAccountId();
|
||
const accountIds: string[] = [];
|
||
for (const [id, account] of Object.entries(this.accounts)) {
|
||
if (id === primaryId) continue;
|
||
if (account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal) {
|
||
accountIds.push(id);
|
||
}
|
||
}
|
||
return [primaryId, ...accountIds];
|
||
}
|
||
|
||
async getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]> {
|
||
const accountId = this.getFilesAccountId();
|
||
const args: Record<string, unknown> = { accountId, ids, properties: properties || JMAPClient.FILE_NODE_PROPERTIES };
|
||
|
||
const response = await this.request(
|
||
[["FileNode/get", args, "fn0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/get failed");
|
||
}
|
||
return ((result[1].list || []) as FileNode[]).map(withDecodedName);
|
||
}
|
||
|
||
async queryFileNodes(filter: FileNodeFilter, sort?: { property: string; isAscending: boolean }[]): Promise<string[]> {
|
||
const accountId = this.getFilesAccountId();
|
||
const args: Record<string, unknown> = { accountId, filter };
|
||
if (sort) args.sort = sort;
|
||
|
||
const response = await this.request(
|
||
[["FileNode/query", args, "fnq0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/query failed");
|
||
}
|
||
return (result[1].ids || []) as string[];
|
||
}
|
||
|
||
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
||
// FileNode/query omits folder nodes in Stalwart (see listAllFileNodes), so
|
||
// we enumerate the whole account and filter children client-side.
|
||
const all = await this.listAllFileNodes();
|
||
return all.filter(n => (n.parentId ?? null) === parentId);
|
||
}
|
||
|
||
/**
|
||
* Fetch every FileNode in the account, files AND folders, to build the folder
|
||
* hierarchy client-side from parentId links.
|
||
*
|
||
* IMPORTANT: this uses `FileNode/get` with `ids: null` (return-all), NOT
|
||
* `FileNode/query`. Stalwart's FileNode/query only returns leaf files - it
|
||
* omits container (folder) nodes entirely - so a query-based listing made
|
||
* every folder invisible (the whole account looked like a single root file).
|
||
*/
|
||
async listAllFileNodes(): Promise<FileNode[]> {
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
const response = await this.request(
|
||
[["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0"));
|
||
if (!getResult || getResult[0] === "error") {
|
||
console.error('[Files] FileNode/get error:', getResult?.[1]);
|
||
throw new Error(getResult?.[1]?.description || "FileNode list failed");
|
||
}
|
||
return ((getResult[1].list || []) as FileNode[]).map(withDecodedName);
|
||
}
|
||
|
||
/**
|
||
* Fetch every FileNode the logged-in user can see across all connected and
|
||
* shared accounts. Nodes owned by another principal (shared with the user)
|
||
* are tagged with `isShared: true` and the owning `accountId`/`accountName`,
|
||
* and their ids are namespaced `accountId:nodeId` so they don't collide with
|
||
* the primary account's ids. Mirrors getAllCalendars().
|
||
*/
|
||
async listAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
||
const primaryId = this.getFilesAccountId();
|
||
const accountIds = this.getFilesCapableAccountIds();
|
||
const all: FileNode[] = [];
|
||
|
||
for (const accountId of accountIds) {
|
||
const isPrimary = accountId === primaryId;
|
||
const account = this.accounts[accountId];
|
||
try {
|
||
const response = await this.request(
|
||
[["FileNode/get", { accountId, ids: null, properties: JMAPClient.FILE_NODE_PROPERTIES }, "fng0"]],
|
||
this.fileUsing(),
|
||
);
|
||
const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get");
|
||
if (!getResult || getResult[0] === "error") continue;
|
||
const nodes = (getResult[1].list || []) as FileNode[];
|
||
for (const node of nodes) {
|
||
all.push({
|
||
...withDecodedName(node),
|
||
id: isPrimary ? node.id : `${accountId}:${node.id}`,
|
||
parentId: node.parentId == null
|
||
? null
|
||
: (isPrimary ? node.parentId : `${accountId}:${node.parentId}`),
|
||
accountId,
|
||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||
isShared: !isPrimary,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Files] Failed to fetch FileNodes for account ${accountId}:`, error);
|
||
}
|
||
}
|
||
|
||
return all;
|
||
}
|
||
|
||
/**
|
||
* Add, update, or remove a principal's rights on a FileNode (file or folder).
|
||
* Pass `rights: null` to revoke access. Mirrors setCalendarShare /
|
||
* setAddressBookShare; Stalwart applies it via a `shareWith/{principalId}`
|
||
* patch on FileNode/set.
|
||
*/
|
||
async setFileNodeShare(
|
||
fileNodeId: string,
|
||
principalId: string,
|
||
rights: FileNodeRights | null,
|
||
targetAccountId?: string,
|
||
): Promise<void> {
|
||
const accountId = targetAccountId || this.getFilesAccountId();
|
||
const response = await this.request([
|
||
["FileNode/set", {
|
||
accountId,
|
||
update: { [fileNodeId]: { [`shareWith/${principalId}`]: rights } },
|
||
}, "0"],
|
||
], this.fileUsing());
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notUpdated?.[fileNodeId]) {
|
||
const err = result.notUpdated[fileNodeId];
|
||
throw new Error(err.description || "Failed to update file share");
|
||
}
|
||
if (!result?.updated || !(fileNodeId in result.updated)) {
|
||
throw new Error("Server did not confirm the share update");
|
||
}
|
||
}
|
||
|
||
async createFileDirectory(name: string, parentId: string | null): Promise<FileNode> {
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
// A FileNode is a folder (container) only when it has no content - i.e. no
|
||
// blobId, type or size, so the server stores it with `file == null`. Sending
|
||
// any of those - as older builds did (type "d" + an empty blob) - makes it a
|
||
// 0-byte FILE that nothing can ever be parented under, which is what caused
|
||
// "Parent ID does not exist or is not a folder" during the #379 migration.
|
||
const dirProps: Record<string, unknown> = { name };
|
||
if (parentId !== null) {
|
||
dirProps.parentId = parentId;
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
create: {
|
||
dir0: dirProps,
|
||
},
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set create failed");
|
||
}
|
||
const created = result[1].created?.dir0;
|
||
if (!created) {
|
||
const err = result[1].notCreated?.dir0;
|
||
throw new Error(err?.description || "Failed to create directory");
|
||
}
|
||
return created as FileNode;
|
||
}
|
||
|
||
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
//fall back for long MIME types
|
||
const safeType = type.length > 30 ? 'application/octet-stream' : type;
|
||
const fileProps: Record<string, unknown> = { name, type: safeType, blobId, size };
|
||
if (parentId !== null) {
|
||
fileProps.parentId = parentId;
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
create: {
|
||
file0: fileProps,
|
||
},
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set create failed");
|
||
}
|
||
const created = result[1].created?.file0;
|
||
if (!created) {
|
||
const err = result[1].notCreated?.file0;
|
||
throw new Error(err?.description || "Failed to create file node");
|
||
}
|
||
return created as FileNode;
|
||
}
|
||
|
||
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
update: { [id]: updates },
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||
}
|
||
if (result[1].notUpdated?.[id]) {
|
||
throw new Error(result[1].notUpdated[id].description || "Failed to update file node");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update many FileNodes in a single FileNode/set call. Returns the ids that
|
||
* were updated and a map of id -> error for any the server rejected. Never
|
||
* throws for per-node failures (only for a whole-method error).
|
||
*/
|
||
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
||
const entries = Object.entries(updates);
|
||
if (entries.length === 0) return { updated: [], notUpdated: {} };
|
||
const accountId = this.getFilesAccountId();
|
||
|
||
const updated: string[] = [];
|
||
const notUpdated: Record<string, string> = {};
|
||
|
||
for (const batch of batched(entries, this.getMaxObjectsInSet())) {
|
||
const response = await this.request(
|
||
[["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set update failed");
|
||
}
|
||
|
||
const updatedMap: Record<string, unknown> = result[1].updated || {};
|
||
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
|
||
for (const id of Object.keys(notUpdatedMap)) {
|
||
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
|
||
}
|
||
// Servers may omit the `updated` map; treat anything not rejected as updated.
|
||
updated.push(...(Object.keys(updatedMap).length > 0
|
||
? Object.keys(updatedMap)
|
||
: batch.map(([id]) => id).filter(id => !(id in notUpdated))));
|
||
}
|
||
|
||
return { updated, notUpdated };
|
||
}
|
||
|
||
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
|
||
const accountId = this.getFilesAccountId();
|
||
const destroyed: string[] = [];
|
||
|
||
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
destroy: batch,
|
||
onDestroyRemoveChildren: true,
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
|
||
}
|
||
|
||
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
|
||
const notDestroyedIds = Object.keys(notDestroyedMap);
|
||
|
||
if (notDestroyedIds.length > 0) {
|
||
const firstError = notDestroyedMap[notDestroyedIds[0]];
|
||
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
|
||
}
|
||
|
||
destroyed.push(...(result[1].destroyed || []));
|
||
}
|
||
|
||
return { destroyed, notDestroyed: [] };
|
||
}
|
||
|
||
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
|
||
// Copy: get original, upload blob reference, create new node
|
||
const nodes = await this.getFileNodes([id]);
|
||
if (nodes.length === 0) throw new Error('File node not found');
|
||
const original = nodes[0];
|
||
|
||
const accountId = this.getFilesAccountId();
|
||
const createProps: Record<string, unknown> = {
|
||
name: newName,
|
||
type: original.type,
|
||
blobId: original.blobId,
|
||
size: original.size,
|
||
};
|
||
if (parentId !== null) {
|
||
createProps.parentId = parentId;
|
||
}
|
||
|
||
const response = await this.request(
|
||
[["FileNode/set", {
|
||
accountId,
|
||
create: {
|
||
copy0: createProps,
|
||
},
|
||
}, "fns0"]],
|
||
this.fileUsing(),
|
||
);
|
||
|
||
const result = response.methodResponses?.[0];
|
||
if (!result || result[0] === "error") {
|
||
throw new Error(result?.[1]?.description || "FileNode copy failed");
|
||
}
|
||
const created = result[1].created?.copy0;
|
||
if (!created) {
|
||
const err = result[1].notCreated?.copy0;
|
||
throw new Error(err?.description || "Failed to copy file node");
|
||
}
|
||
return created as FileNode;
|
||
}
|
||
|
||
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
|
||
const blob = await this.fetchBlob(blobId, name, type, accountId);
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = blobUrl;
|
||
a.download = name || 'download';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(blobUrl);
|
||
}
|
||
|
||
private pollingInterval: NodeJS.Timeout | null = null;
|
||
private secondaryPollInterval: NodeJS.Timeout | null = null;
|
||
private pollingStates: { [key: string]: string } = {};
|
||
private sseAbortController: AbortController | null = null;
|
||
private sseReconnectTimeout: NodeJS.Timeout | null = null;
|
||
private ssePingTimer: NodeJS.Timeout | null = null;
|
||
private lastSSEActivity: number = 0;
|
||
// Guards a state-change poll from stacking: the 3s interval, the secondary
|
||
// poll, and the visibility/online handlers all call checkForStateChanges, so
|
||
// under socket pressure they would otherwise pile identical POSTs onto an
|
||
// already-saturated pool and amplify the queueing (#781).
|
||
private stateCheckInFlight: boolean = false;
|
||
private visibilityHandler: (() => void) | null = null;
|
||
private onlineHandler: (() => void) | null = null;
|
||
|
||
// Per-tab SSE budget (#702). Every logged-in account is its own JMAPClient
|
||
// and each one holds its push stream open as a long-lived fetch - one pinned
|
||
// socket per account. Browsers allow only 6 HTTP/1.1 connections per host,
|
||
// so from the sixth login on there is no socket left for an ordinary JMAP
|
||
// POST: sends grey out and never complete, moves and loads queue for
|
||
// minutes, all until one of the streams happens to drop. Cap the number of
|
||
// concurrent streams per tab well below that limit (two tabs of the app
|
||
// share the same pool) and let the remaining logins fall back to the slow
|
||
// state poll, which borrows a socket for a moment instead of pinning it.
|
||
// Waiters are promoted in order as holders release their slot.
|
||
static readonly MAX_SSE_STREAMS = 2;
|
||
private static sseHolders = new Set<JMAPClient>();
|
||
private static sseWaiters: JMAPClient[] = [];
|
||
private static ssePromotionQueued = false;
|
||
// True between connectSSE and closePushNotifications: this client wants a
|
||
// live stream and may be promoted into one when a slot frees up.
|
||
private sseWanted = false;
|
||
|
||
private static acquireSSESlot(client: JMAPClient): boolean {
|
||
if (JMAPClient.sseHolders.has(client)) return true;
|
||
if (JMAPClient.sseHolders.size >= JMAPClient.MAX_SSE_STREAMS) return false;
|
||
JMAPClient.sseHolders.add(client);
|
||
JMAPClient.sseWaiters = JMAPClient.sseWaiters.filter((c) => c !== client);
|
||
return true;
|
||
}
|
||
|
||
private static releaseSSESlot(client: JMAPClient): void {
|
||
JMAPClient.sseWaiters = JMAPClient.sseWaiters.filter((c) => c !== client);
|
||
if (!JMAPClient.sseHolders.delete(client)) return;
|
||
// Promote on a microtask, not inline: the push effect tears down every
|
||
// client and immediately sets them all up again (account switch), and a
|
||
// synchronous promotion would open a stream for a waiter that is about to
|
||
// be closed a few statements later.
|
||
if (JMAPClient.ssePromotionQueued) return;
|
||
JMAPClient.ssePromotionQueued = true;
|
||
queueMicrotask(() => {
|
||
JMAPClient.ssePromotionQueued = false;
|
||
JMAPClient.promoteSSEWaiters();
|
||
});
|
||
}
|
||
|
||
private static enqueueSSEWaiter(client: JMAPClient): void {
|
||
if (!JMAPClient.sseWaiters.includes(client)) JMAPClient.sseWaiters.push(client);
|
||
}
|
||
|
||
private static promoteSSEWaiters(): void {
|
||
while (JMAPClient.sseHolders.size < JMAPClient.MAX_SSE_STREAMS && JMAPClient.sseWaiters.length > 0) {
|
||
const next = JMAPClient.sseWaiters.shift()!;
|
||
if (!next.sseWanted || next.intentionallyDisconnected) continue;
|
||
const url = next.getEventSourceUrl();
|
||
if (!url) continue;
|
||
// Leave the slow poll running only if it is still needed for shared
|
||
// accounts; the SSE branch of setup re-creates it in that case.
|
||
if (next.secondaryPollInterval) {
|
||
clearInterval(next.secondaryPollInterval);
|
||
next.secondaryPollInterval = null;
|
||
}
|
||
next.connectSSE(url);
|
||
next.startSecondaryAccountPoll();
|
||
}
|
||
}
|
||
|
||
/** Number of SSE streams currently held open by clients in this tab. */
|
||
static activeSSEStreamCount(): number {
|
||
return JMAPClient.sseHolders.size;
|
||
}
|
||
|
||
/** Whether this client holds one of the tab's SSE slots (vs. slow-polling). */
|
||
hasSSEStream(): boolean {
|
||
return JMAPClient.sseHolders.has(this);
|
||
}
|
||
|
||
private static readonly STATE_TYPE_MAP: Record<string, string> = {
|
||
'Mailbox/get': 'Mailbox',
|
||
'Email/get': 'Email',
|
||
'Calendar/get': 'Calendar',
|
||
'CalendarEvent/get': 'CalendarEvent',
|
||
'SieveScript/get': 'SieveScript',
|
||
};
|
||
|
||
private static readonly POLLING_INTERVAL = 3_000;
|
||
// Shared/secondary accounts get no SSE push (Stalwart pushes the primary
|
||
// account only), so poll them on a slow cadence alongside SSE to keep their
|
||
// folder + unified/All-Mail counters from going stale between focus events.
|
||
private static readonly SECONDARY_POLL_INTERVAL = 20_000;
|
||
private static readonly SSE_RECONNECT_DELAY = 3_000;
|
||
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
|
||
|
||
setupPushNotifications(): boolean {
|
||
const eventSourceUrl = this.getEventSourceUrl();
|
||
if (eventSourceUrl) {
|
||
this.connectSSE(eventSourceUrl);
|
||
// SSE covers the primary account only; keep shared accounts fresh too.
|
||
this.startSecondaryAccountPoll();
|
||
} else {
|
||
// The fallback poll already covers every session account.
|
||
this.startPollingFallback();
|
||
}
|
||
this.setupBrowserEventListeners();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Slow poll of the session's shared/secondary accounts, run in parallel with
|
||
* SSE (which never reports them). Skipped when there are no shared accounts,
|
||
* and paused while the tab is hidden (visibilitychange forces a check on
|
||
* return). Reuses checkForStateChanges, which already reports per-account.
|
||
*/
|
||
private startSecondaryAccountPoll(): void {
|
||
if (this.secondaryPollInterval) return;
|
||
const hasSecondary = this.pollAccountIds().some((id) => id !== this.accountId);
|
||
if (!hasSecondary) return;
|
||
this.startSlowPoll();
|
||
}
|
||
|
||
/**
|
||
* The slow (20s) state poll over every account this login can see. Used
|
||
* alongside SSE for shared accounts, and on its own for logins that did not
|
||
* get one of the tab's SSE slots (#702) - it covers the primary account too.
|
||
*/
|
||
private startSlowPoll(): void {
|
||
if (this.secondaryPollInterval) return;
|
||
// Prime the per-account baseline so the first tick doesn't false-fire.
|
||
void this.fetchCurrentStates();
|
||
this.secondaryPollInterval = setInterval(() => {
|
||
if (typeof document !== 'undefined' && document.hidden) return;
|
||
void this.checkForStateChanges();
|
||
}, JMAPClient.SECONDARY_POLL_INTERVAL);
|
||
}
|
||
|
||
private connectSSE(templateUrl: string): void {
|
||
this.sseWanted = true;
|
||
if (this.isRateLimited()) {
|
||
this.scheduleSSEReconnect();
|
||
return;
|
||
}
|
||
|
||
// Budget check (#702): with no slot left in this tab, keep this login on
|
||
// the slow poll and queue it for promotion once a holder releases. It
|
||
// must not pin a sixth socket and starve every other request.
|
||
if (!JMAPClient.acquireSSESlot(this)) {
|
||
JMAPClient.enqueueSSEWaiter(this);
|
||
this.startSlowPoll();
|
||
return;
|
||
}
|
||
|
||
const url = templateUrl
|
||
.replace('{types}', '*')
|
||
.replace('{closeafter}', 'no')
|
||
.replace('{ping}', '30');
|
||
|
||
// Already-active guard (#781): never stack a second SSE stream on a live
|
||
// one. readSSEStream only unwinds on abort or stream-end, so a previous
|
||
// connect left open would hold its HTTP/1.1 socket indefinitely; with
|
||
// several accounts each pinning a socket, routine JMAP POSTs then queue and
|
||
// time out. Abort any prior stream (and its ping monitor / pending
|
||
// reconnect) before opening the replacement. The old read loop unwinds via
|
||
// AbortError and, because we install a fresh controller below, its
|
||
// end-of-stream reconnect guard (`sseAbortController === controller`) no
|
||
// longer matches, so it does not spawn a competing connection.
|
||
if (this.sseAbortController) {
|
||
this.sseAbortController.abort();
|
||
this.sseAbortController = null;
|
||
}
|
||
this.stopSSEPingMonitor();
|
||
if (this.sseReconnectTimeout) {
|
||
clearTimeout(this.sseReconnectTimeout);
|
||
this.sseReconnectTimeout = null;
|
||
}
|
||
|
||
// Each attempt tracks its own controller. When closePushNotifications
|
||
// aborts a connect that is still in flight (every account switch tears
|
||
// down and re-creates push for all connected clients), the rejection
|
||
// lands in the catch below AFTER the next attempt has already been set
|
||
// up - treating it as a network failure there would spawn an
|
||
// unsupervised polling interval and, via fallbackToPolling nulling
|
||
// sseAbortController, orphan the replacement connection.
|
||
const controller = new AbortController();
|
||
this.sseAbortController = controller;
|
||
|
||
this.authenticatedFetch(url, {
|
||
headers: { 'Accept': 'text/event-stream' },
|
||
signal: controller.signal,
|
||
}).then(response => {
|
||
if (controller.signal.aborted) return;
|
||
if (!response.ok || !response.body) {
|
||
this.fallbackToPolling();
|
||
return;
|
||
}
|
||
this.readSSEStream(response.body, controller);
|
||
}).catch((error) => {
|
||
// Intentional close, not a failure - no polling fallback.
|
||
if (controller.signal.aborted) return;
|
||
if (error instanceof RateLimitError) {
|
||
this.sseAbortController = null;
|
||
this.scheduleSSEReconnect();
|
||
return;
|
||
}
|
||
this.fallbackToPolling();
|
||
});
|
||
}
|
||
|
||
private async readSSEStream(body: ReadableStream<Uint8Array>, controller: AbortController): Promise<void> {
|
||
const reader = body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
this.lastSSEActivity = Date.now();
|
||
this.startSSEPingMonitor();
|
||
|
||
try {
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
this.lastSSEActivity = Date.now();
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const parts = buffer.split('\n\n');
|
||
buffer = parts.pop() || '';
|
||
|
||
for (const part of parts) {
|
||
this.processSSEEvent(part);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||
}
|
||
|
||
this.stopSSEPingMonitor();
|
||
|
||
// Stream ended - reconnect only if this stream is still the current one
|
||
// and was not intentionally closed. A superseded stream must not spawn a
|
||
// second connection next to its replacement.
|
||
if (this.sseAbortController === controller && !controller.signal.aborted) {
|
||
this.scheduleSSEReconnect();
|
||
}
|
||
}
|
||
|
||
private processSSEEvent(raw: string): void {
|
||
let eventType = 'message';
|
||
let dataLines: string[] = [];
|
||
|
||
for (const line of raw.split('\n')) {
|
||
if (line.startsWith('event:')) {
|
||
eventType = line.slice(6).trim();
|
||
} else if (line.startsWith('data:')) {
|
||
dataLines.push(line.slice(5).trim());
|
||
}
|
||
}
|
||
|
||
if (eventType === 'state' && dataLines.length > 0) {
|
||
try {
|
||
const change = JSON.parse(dataLines.join('\n')) as StateChange;
|
||
this.stateChangeCallback?.(change);
|
||
} catch {
|
||
// Malformed SSE data - ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
private scheduleSSEReconnect(): void {
|
||
if (this.intentionallyDisconnected) return;
|
||
const eventSourceUrl = this.getEventSourceUrl();
|
||
if (!eventSourceUrl) {
|
||
this.fallbackToPolling();
|
||
return;
|
||
}
|
||
const delay = this.isRateLimited()
|
||
? Math.max(this.rateLimitedUntil - Date.now(), JMAPClient.SSE_RECONNECT_DELAY)
|
||
: JMAPClient.SSE_RECONNECT_DELAY;
|
||
this.sseReconnectTimeout = setTimeout(() => {
|
||
if (this.isRateLimited()) {
|
||
this.scheduleSSEReconnect();
|
||
return;
|
||
}
|
||
this.connectSSE(eventSourceUrl);
|
||
}, delay);
|
||
}
|
||
|
||
private fallbackToPolling(): void {
|
||
this.sseAbortController = null;
|
||
// No stream is held any more - hand the slot to a waiting login.
|
||
JMAPClient.releaseSSESlot(this);
|
||
if (!this.pollingInterval) {
|
||
this.startPollingFallback();
|
||
}
|
||
}
|
||
|
||
private startPollingFallback(): void {
|
||
if (this.intentionallyDisconnected) return;
|
||
if (this.isRateLimited()) {
|
||
return;
|
||
}
|
||
// Idempotent: a repeat setup (or an SSE->poll fallback that races another)
|
||
// must not leak a second 3s interval alongside the first (#781).
|
||
if (this.pollingInterval) return;
|
||
this.fetchCurrentStates();
|
||
this.pollingInterval = setInterval(() => {
|
||
this.checkForStateChanges();
|
||
}, JMAPClient.POLLING_INTERVAL);
|
||
}
|
||
|
||
/**
|
||
* Accounts whose Mailbox/Email state the poll should track. Stalwart's SSE
|
||
* only pushes StateChange for the primary account, never for delegated/shared
|
||
* (secondary) accounts, so their folder counters — and the unified/All-Mail
|
||
* badges that aggregate them — would otherwise never refresh from a background
|
||
* change. Polling every session account (primary + shared) closes that gap on
|
||
* the visibility/interval reconcile path. Mailbox/Email get callIds are tagged
|
||
* with the accountId (`mbx:<id>` / `eml:<id>`) so each account is compared
|
||
* independently. (#shared-counter-push)
|
||
*/
|
||
private pollAccountIds(): string[] {
|
||
const ids = Object.keys(this.accounts || {});
|
||
return ids.length > 0 ? ids : [this.accountId];
|
||
}
|
||
|
||
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
|
||
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
||
const methodCalls: JMAPMethodCall[] = [];
|
||
for (const acctId of this.pollAccountIds()) {
|
||
methodCalls.push(
|
||
['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`],
|
||
['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`],
|
||
);
|
||
}
|
||
|
||
if (this.supportsCalendars()) {
|
||
using.push('urn:ietf:params:jmap:calendars');
|
||
const calAccountId = this.getCalendarsAccountId();
|
||
methodCalls.push(
|
||
['Calendar/get', { accountId: calAccountId, ids: null, properties: ['id'] }, 'c'],
|
||
['CalendarEvent/get', { accountId: calAccountId, ids: [], properties: ['id'] }, 'd'],
|
||
);
|
||
}
|
||
|
||
if (this.supportsSieve()) {
|
||
using.push('urn:ietf:params:jmap:sieve');
|
||
methodCalls.push(
|
||
['SieveScript/get', { accountId: this.getSieveAccountId(), ids: [], properties: ['id'] }, 'e'],
|
||
);
|
||
}
|
||
|
||
return { using, methodCalls };
|
||
}
|
||
|
||
/** Map a polled method response back to its (accountId, stateKey). */
|
||
private resolvePolledState(method: string, callId: unknown): { accountId: string; stateKey: string } | null {
|
||
if (typeof callId === 'string') {
|
||
if (callId.startsWith('mbx:')) return { accountId: callId.slice(4), stateKey: 'Mailbox' };
|
||
if (callId.startsWith('eml:')) return { accountId: callId.slice(4), stateKey: 'Email' };
|
||
}
|
||
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
|
||
return stateKey ? { accountId: this.accountId, stateKey } : null;
|
||
}
|
||
|
||
private async fetchCurrentStates(): Promise<void> {
|
||
if (this.isRateLimited()) {
|
||
return;
|
||
}
|
||
try {
|
||
const { using, methodCalls } = this.buildStatePollingRequest();
|
||
const response = await this.firstTouchGate.run(methodCalls, () =>
|
||
this.authenticatedFetch(this.apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ using, methodCalls }),
|
||
}),
|
||
);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
for (const [method, result, callId] of data.methodResponses) {
|
||
const resolved = this.resolvePolledState(method, callId);
|
||
if (resolved && result?.state) {
|
||
this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state;
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Silently fail - polling will retry
|
||
}
|
||
}
|
||
|
||
private async checkForStateChanges(): Promise<void> {
|
||
if (this.isRateLimited()) {
|
||
return;
|
||
}
|
||
// Coalesce overlapping polls: if one is already awaiting a response, skip
|
||
// this call rather than issuing a duplicate POST (#781). The next tick
|
||
// picks up any change once the in-flight one settles.
|
||
if (this.stateCheckInFlight) {
|
||
return;
|
||
}
|
||
this.stateCheckInFlight = true;
|
||
try {
|
||
const { using, methodCalls } = this.buildStatePollingRequest();
|
||
const response = await this.firstTouchGate.run(methodCalls, () =>
|
||
this.authenticatedFetch(this.apiUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ using, methodCalls }),
|
||
}),
|
||
);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
// Build a per-account changed map so a background change in a shared
|
||
// (secondary) account is reported under its own accountId — which
|
||
// handleStateChange treats as "some mailbox changed" and refetches the
|
||
// full (own + delegated) mailbox list from.
|
||
const changedByAccount: Record<string, Record<string, string>> = {};
|
||
|
||
for (const [method, result, callId] of data.methodResponses) {
|
||
const resolved = this.resolvePolledState(method, callId);
|
||
if (!resolved || !result?.state) continue;
|
||
const key = `${resolved.accountId}:${resolved.stateKey}`;
|
||
if (this.pollingStates[key] && this.pollingStates[key] !== result.state) {
|
||
(changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state;
|
||
}
|
||
this.pollingStates[key] = result.state;
|
||
}
|
||
|
||
if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) {
|
||
this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount });
|
||
}
|
||
}
|
||
} catch {
|
||
// Silently fail - polling will retry
|
||
} finally {
|
||
this.stateCheckInFlight = false;
|
||
}
|
||
}
|
||
|
||
closePushNotifications(): void {
|
||
this.sseWanted = false;
|
||
JMAPClient.releaseSSESlot(this);
|
||
if (this.pollingInterval) {
|
||
clearInterval(this.pollingInterval);
|
||
this.pollingInterval = null;
|
||
}
|
||
if (this.secondaryPollInterval) {
|
||
clearInterval(this.secondaryPollInterval);
|
||
this.secondaryPollInterval = null;
|
||
}
|
||
if (this.sseAbortController) {
|
||
this.sseAbortController.abort();
|
||
this.sseAbortController = null;
|
||
}
|
||
if (this.sseReconnectTimeout) {
|
||
clearTimeout(this.sseReconnectTimeout);
|
||
this.sseReconnectTimeout = null;
|
||
}
|
||
if (this.eventSource) {
|
||
this.eventSource.close();
|
||
this.eventSource = null;
|
||
}
|
||
this.stopSSEPingMonitor();
|
||
this.cleanupBrowserEventListeners();
|
||
this.stateChangeCallback = null;
|
||
this.pollingStates = {};
|
||
}
|
||
|
||
private startSSEPingMonitor(): void {
|
||
this.stopSSEPingMonitor();
|
||
this.ssePingTimer = setInterval(() => {
|
||
if (Date.now() - this.lastSSEActivity > JMAPClient.SSE_PING_TIMEOUT) {
|
||
// SSE connection is stale - abort and reconnect
|
||
this.stopSSEPingMonitor();
|
||
if (this.sseAbortController) {
|
||
this.sseAbortController.abort();
|
||
this.sseAbortController = null;
|
||
}
|
||
this.scheduleSSEReconnect();
|
||
}
|
||
}, 30_000);
|
||
}
|
||
|
||
private stopSSEPingMonitor(): void {
|
||
if (this.ssePingTimer) {
|
||
clearInterval(this.ssePingTimer);
|
||
this.ssePingTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Drop and rebuild an SSE stream that has gone quiet past the ping deadline.
|
||
*
|
||
* The ping monitor already does this, but it is a `setInterval`, and a
|
||
* suspended tab - an iOS home-screen web app in particular - freezes its
|
||
* timers along with everything else. Coming back to a page whose push
|
||
* connection died while it slept, the monitor needs up to a full tick just to
|
||
* notice, and the connection it is holding is one the OS already tore down.
|
||
* Checking on the way back in makes the recovery immediate rather than
|
||
* leaving the app on a socket that will never deliver another byte.
|
||
*/
|
||
private recycleStaleSSE(): void {
|
||
if (this.intentionallyDisconnected) return;
|
||
if (!this.sseAbortController) return;
|
||
if (Date.now() - this.lastSSEActivity <= JMAPClient.SSE_PING_TIMEOUT) return;
|
||
|
||
this.stopSSEPingMonitor();
|
||
this.sseAbortController.abort();
|
||
this.sseAbortController = null;
|
||
this.scheduleSSEReconnect();
|
||
}
|
||
|
||
private setupBrowserEventListeners(): void {
|
||
if (typeof document !== 'undefined') {
|
||
this.visibilityHandler = () => {
|
||
if (!document.hidden) {
|
||
// Tab became visible - immediately check for state changes
|
||
this.checkForStateChanges();
|
||
this.recycleStaleSSE();
|
||
}
|
||
};
|
||
document.addEventListener('visibilitychange', this.visibilityHandler);
|
||
}
|
||
|
||
if (typeof window !== 'undefined') {
|
||
this.onlineHandler = () => {
|
||
// Network reconnected - reconnect SSE or force a poll
|
||
const eventSourceUrl = this.getEventSourceUrl();
|
||
if (eventSourceUrl && !this.sseAbortController) {
|
||
this.connectSSE(eventSourceUrl);
|
||
} else {
|
||
this.checkForStateChanges();
|
||
}
|
||
};
|
||
window.addEventListener('online', this.onlineHandler);
|
||
}
|
||
}
|
||
|
||
private cleanupBrowserEventListeners(): void {
|
||
if (this.visibilityHandler && typeof document !== 'undefined') {
|
||
document.removeEventListener('visibilitychange', this.visibilityHandler);
|
||
this.visibilityHandler = null;
|
||
}
|
||
if (this.onlineHandler && typeof window !== 'undefined') {
|
||
window.removeEventListener('online', this.onlineHandler);
|
||
this.onlineHandler = null;
|
||
}
|
||
}
|
||
|
||
onConnectionChange(callback: (connected: boolean) => void): void {
|
||
this.connectionChangeCallback = callback;
|
||
}
|
||
|
||
onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void {
|
||
this.rateLimitCallback = callback;
|
||
}
|
||
|
||
isRateLimited(): boolean {
|
||
return Date.now() < this.rateLimitedUntil;
|
||
}
|
||
|
||
getRateLimitRemainingMs(): number {
|
||
return Math.max(0, this.rateLimitedUntil - Date.now());
|
||
}
|
||
|
||
private setRateLimited(retryAfterMs: number): void {
|
||
this.rateLimitedUntil = Date.now() + retryAfterMs;
|
||
|
||
if (this.rateLimitTimeout) {
|
||
clearTimeout(this.rateLimitTimeout);
|
||
this.rateLimitTimeout = null;
|
||
}
|
||
|
||
this.rateLimitCallback?.(true, retryAfterMs);
|
||
|
||
// Pause live updates until the server's rate-limit window expires.
|
||
const stateChangeCallback = this.stateChangeCallback;
|
||
this.closePushNotifications();
|
||
this.stateChangeCallback = stateChangeCallback;
|
||
|
||
// Schedule clearing the rate-limit flag and notifying listeners.
|
||
this.rateLimitTimeout = setTimeout(() => {
|
||
this.rateLimitTimeout = null;
|
||
if (!this.isRateLimited()) {
|
||
this.rateLimitCallback?.(false, 0);
|
||
if (this.session && this.stateChangeCallback) {
|
||
this.setupPushNotifications();
|
||
}
|
||
}
|
||
}, retryAfterMs);
|
||
}
|
||
|
||
private notifyRateLimitBlocked(retryAfterMs: number): void {
|
||
if (typeof window === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const now = Date.now();
|
||
if ((now - this.lastRateLimitNoticeAt) < JMAPClient.RATE_LIMIT_TOAST_THROTTLE_MS) {
|
||
return;
|
||
}
|
||
|
||
this.lastRateLimitNoticeAt = now;
|
||
window.dispatchEvent(new CustomEvent('bulwark:rate-limit-blocked', {
|
||
detail: { retryAfterMs },
|
||
}));
|
||
}
|
||
|
||
private static parseRetryAfter(response: Response): number {
|
||
const header = response.headers.get('Retry-After');
|
||
if (!header) return 60_000; // default 60s if no header
|
||
const seconds = Number(header);
|
||
if (!Number.isNaN(seconds) && seconds > 0) {
|
||
return Math.min(seconds * 1000, 300_000); // cap at 5 minutes
|
||
}
|
||
// Try HTTP-date format
|
||
const date = Date.parse(header);
|
||
if (!Number.isNaN(date)) {
|
||
const ms = date - Date.now();
|
||
return ms > 0 ? Math.min(ms, 300_000) : 60_000;
|
||
}
|
||
return 60_000;
|
||
}
|
||
|
||
onStateChange(callback: (change: StateChange) => void): void {
|
||
this.stateChangeCallback = callback;
|
||
}
|
||
|
||
getLastStates(): AccountStates {
|
||
return { ...this.lastStates };
|
||
}
|
||
|
||
setLastStates(states: AccountStates): void {
|
||
this.lastStates = { ...states };
|
||
}
|
||
|
||
// ── S/MIME raw-email helpers ─────────────────────────────────────
|
||
|
||
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
|
||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string, rangeHeader?: number): Promise<ArrayBuffer> {
|
||
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||
const response = await this.authenticatedFetch(url, { }, { timeoutMs: JMAPClient.TRANSFER_TIMEOUT_MS });
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||
}
|
||
|
||
const maxBytes = rangeHeader;
|
||
|
||
// If no Range header requested or stream is missing, fallback to standard arrayBuffer()
|
||
if (!maxBytes || !response.body) {
|
||
return response.arrayBuffer();
|
||
}
|
||
// used to download only the first N bytes of a blob, e.g. for S/MIME /PGP encryption verification
|
||
// Currently, Stalwart server does not support Range requests for the download endpoint,
|
||
// so we have to read the stream and cancel it after reading the first N bytes.
|
||
// Stream reading with early termination via reader.cancel()
|
||
const reader = response.body.getReader();
|
||
const chunks: Uint8Array[] = [];
|
||
let bytesRead = 0;
|
||
|
||
try {
|
||
while (bytesRead < maxBytes) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
chunks.push(value);
|
||
bytesRead += value.length;
|
||
}
|
||
} finally {
|
||
// Cancel the response body stream to close the network connection early
|
||
await reader.cancel().catch(() => {});
|
||
}
|
||
|
||
// Slice exact requested length and copy chunks into a contiguous ArrayBuffer
|
||
const totalLength = Math.min(bytesRead, maxBytes);
|
||
const result = new Uint8Array(totalLength);
|
||
let offset = 0;
|
||
|
||
// Iterate over each chunk of binary data (Uint8Array) collected during the stream
|
||
for (const chunk of chunks) {
|
||
// Determine how many bytes to copy from the current chunk without exceeding totalLength
|
||
const bytesToCopy = Math.min(chunk.length, totalLength - offset);
|
||
// Copy the needed slice from the chunk into the target array starting at the current offset
|
||
result.set(chunk.subarray(0, bytesToCopy), offset);
|
||
// Move the offset forward by the number of bytes just written
|
||
offset += bytesToCopy;
|
||
// Stop processing further chunks if the target size limit has been reached
|
||
if (offset >= totalLength) break;
|
||
}
|
||
|
||
return result.buffer;
|
||
}
|
||
|
||
/**
|
||
* Import a raw MIME message blob into the account. Pass `accountId` to
|
||
* target a delegated account the caller has rights on (e.g. importing into
|
||
* a shared mailbox owned by another user). When omitted, falls back to the
|
||
* client's own primary account.
|
||
*/
|
||
async copyEmailAcrossAccounts(
|
||
emailId: string,
|
||
fromAccountId: string,
|
||
toAccountId: string,
|
||
destMailboxId: string,
|
||
): Promise<string> {
|
||
// Email/copy drops keywords unless the create sets them, so carry the
|
||
// source's over — otherwise the moved message shows up as unread.
|
||
const srcResp = await this.request([
|
||
["Email/get", { accountId: fromAccountId, ids: [emailId], properties: ["keywords"] }, "0"],
|
||
]);
|
||
const keywords = srcResp.methodResponses?.[0]?.[1]?.list?.[0]?.keywords ?? {};
|
||
|
||
// onSuccessDestroyOriginal is the spec-correct way to remove the source, but
|
||
// Stalwart currently destroys the copy's create-id instead of the source id,
|
||
// so the original is left behind — a duplicate on every cross-account move.
|
||
// Reported upstream (support.stalw.art #1150); this self-heals once fixed.
|
||
const response = await this.request([
|
||
["Email/copy", {
|
||
fromAccountId,
|
||
accountId: toAccountId,
|
||
create: { c: { id: emailId, mailboxIds: { [destMailboxId]: true }, keywords } },
|
||
onSuccessDestroyOriginal: true,
|
||
}, "0"],
|
||
]);
|
||
const res = response.methodResponses?.[0]?.[1];
|
||
const err = res?.notCreated?.c;
|
||
if (err) {
|
||
throw new Error(err.description || err.type || "Failed to copy email across accounts");
|
||
}
|
||
const id = res?.created?.c?.id;
|
||
if (!id) {
|
||
throw new Error("Email/copy succeeded but no ID returned");
|
||
}
|
||
return id;
|
||
}
|
||
|
||
async importRawEmail(
|
||
blob: Blob,
|
||
mailboxIds: Record<string, boolean>,
|
||
keywords?: Record<string, boolean>,
|
||
accountId?: string,
|
||
): Promise<string> {
|
||
const targetAccountId = accountId || this.accountId;
|
||
// First upload the blob. Blob uploads are scoped to an account too —
|
||
// when importing into a delegated account, upload there so the resulting
|
||
// blobId is visible to Email/import on that account.
|
||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||
const { blobId } = await this.uploadBlob(file, targetAccountId);
|
||
|
||
// Then import via Email/import
|
||
const response = await this.request([
|
||
['Email/import', {
|
||
accountId: targetAccountId,
|
||
emails: {
|
||
'smime-import': {
|
||
blobId,
|
||
mailboxIds,
|
||
keywords: keywords ?? { '$seen': true },
|
||
},
|
||
},
|
||
}, '0'],
|
||
]);
|
||
|
||
const importResult = response.methodResponses?.[0]?.[1];
|
||
if (importResult?.notCreated?.['smime-import']) {
|
||
const err = importResult.notCreated['smime-import'];
|
||
throw new Error(err.description || err.type || 'Failed to import email');
|
||
}
|
||
|
||
const emailId = importResult?.created?.['smime-import']?.id;
|
||
if (!emailId) {
|
||
throw new Error('Email import succeeded but no ID returned');
|
||
}
|
||
return emailId;
|
||
}
|
||
|
||
/** Submit an already-imported email for delivery. */
|
||
async submitEmail(emailId: string, identityId: string): Promise<void> {
|
||
const response = await this.request([
|
||
['EmailSubmission/set', {
|
||
accountId: this.getSubmissionAccountId(),
|
||
create: { 'smime-submit': { emailId, identityId } },
|
||
}, '0'],
|
||
]);
|
||
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
if (result?.notCreated?.['smime-submit']) {
|
||
const err = result.notCreated['smime-submit'];
|
||
throw new Error(err.description || err.type || 'Failed to submit email');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Import a raw S/MIME PGP/MIME message, move it to the Sent mailbox, and submit it.
|
||
* Encapsulates the full import → update → submit flow.
|
||
*/
|
||
async sendRawEmail(
|
||
blob: Blob,
|
||
identityId: string,
|
||
sentMailboxId: string,
|
||
draftMailboxId?: string,
|
||
delayedUntil?: string,
|
||
envelopeRecipients?: string[],
|
||
): Promise<SendEmailResult> {
|
||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||
// Upload the raw message
|
||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||
const { blobId } = await this.uploadBlob(file);
|
||
|
||
// Import into Drafts first, then move to Sent after submission succeeds.
|
||
// This avoids encrypt-on-append affecting the SMTP send. See #188.
|
||
const importMailboxId = draftMailboxId || sentMailboxId;
|
||
const identities = await this.getIdentities();
|
||
const identity = identities.find(item => item.id === identityId);
|
||
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||
|
||
const methodCalls: [string, Record<string, unknown>, string][] = [
|
||
['Email/import', {
|
||
accountId: this.accountId,
|
||
emails: {
|
||
'raw-import': {
|
||
blobId,
|
||
mailboxIds: { [importMailboxId]: true },
|
||
keywords: draftMailboxId ? { '$seen': true, '$draft': true } : { '$seen': true },
|
||
},
|
||
},
|
||
}, '0'],
|
||
['EmailSubmission/set', {
|
||
accountId: this.getSubmissionAccountId(),
|
||
create: {
|
||
'raw-submit': {
|
||
emailId: '#raw-import',
|
||
identityId,
|
||
...(envelope ? { envelope } : {}),
|
||
},
|
||
},
|
||
...(draftMailboxId ? {
|
||
onSuccessUpdateEmail: {
|
||
'#raw-submit': {
|
||
...mailboxIdsReplacement(sentMailboxId),
|
||
'keywords/$draft': null,
|
||
},
|
||
},
|
||
} : {}),
|
||
}, '1'],
|
||
];
|
||
|
||
const response = await this.request(methodCalls);
|
||
let emailId: string | undefined;
|
||
let emailSubmissionId: string | undefined;
|
||
let serverSendAt: string | undefined;
|
||
|
||
// Check for errors
|
||
for (const [methodName, result] of response.methodResponses ?? []) {
|
||
if (methodName.endsWith('/error')) {
|
||
throw new Error((result as { description?: string }).description || `Failed: ${(result as { type?: string }).type}`);
|
||
}
|
||
const r = result as { notCreated?: Record<string, { description?: string; type?: string }> };
|
||
if (r.notCreated) {
|
||
const firstErr = Object.values(r.notCreated)[0];
|
||
throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');
|
||
}
|
||
if (methodName === 'Email/import') {
|
||
emailId = (result as { created?: Record<string, { id?: string }> }).created?.['raw-import']?.id;
|
||
}
|
||
if (methodName === 'EmailSubmission/set') {
|
||
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
|
||
emailSubmissionId = created?.id;
|
||
serverSendAt = created?.sendAt;
|
||
}
|
||
}
|
||
|
||
// These raw/S-MIME paths submit through the primary submission account, so
|
||
// report that as the owning account rather than leaving it unset.
|
||
const submissionAccountId = this.getSubmissionAccountId();
|
||
|
||
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||
}
|
||
|
||
return delayedUntil
|
||
? { scheduled: true, emailId, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true }
|
||
: { scheduled: false, emailId, emailSubmissionId, submissionAccountId, isSmime: true };
|
||
}
|
||
|
||
/**
|
||
* Submit a raw email blob to the network via JMAP EmailSubmission without auto-archiving to Sent.
|
||
*/
|
||
async submitRawEmail(
|
||
blob: Blob,
|
||
identityId: string,
|
||
delayedUntil?: string,
|
||
envelopeRecipients?: string[],
|
||
): Promise<SendEmailResult> {
|
||
const mailboxes = await this.getMailboxes();
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
if (!draftsMailbox) {
|
||
throw new Error('Drafts mailbox not found');
|
||
}
|
||
|
||
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
|
||
|
||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||
const { blobId } = await this.uploadBlob(file);
|
||
|
||
const identities = await this.getIdentities();
|
||
const identity = identities.find(item => item.id === identityId);
|
||
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||
|
||
//Temporarily import the raw email into Drafts to satisfy JMAP's requirement that an EmailSubmission references an existing Email.
|
||
// The Email will be destroyed after submission.
|
||
const methodCalls: [string, Record<string, unknown>, string][] = [
|
||
['Email/import', {
|
||
accountId: this.accountId,
|
||
emails: {
|
||
'temp-submit': {
|
||
blobId,
|
||
mailboxIds: { [draftsMailbox.id]: true },
|
||
keywords: { '$draft': true },
|
||
},
|
||
},
|
||
}, '0'],
|
||
['EmailSubmission/set', {
|
||
accountId: this.getSubmissionAccountId(),
|
||
create: {
|
||
'raw-submit': {
|
||
emailId: '#temp-submit',
|
||
identityId,
|
||
...(envelope ? { envelope } : {}),
|
||
},
|
||
},
|
||
//destroy the temporary email after submission to avoid leaving a draft behind.
|
||
onSuccessDestroyEmail: ['#raw-submit'],
|
||
}, '1'],
|
||
];
|
||
|
||
const response = await this.request(methodCalls);
|
||
let emailSubmissionId: string | undefined;
|
||
let serverSendAt: string | undefined;
|
||
|
||
for (const [methodName, result] of response.methodResponses ?? []) {
|
||
if (methodName.endsWith('/error')) {
|
||
throw new Error((result as { description?: string }).description || `Failed: ${(result as { type?: string }).type}`);
|
||
}
|
||
const r = result as { notCreated?: Record<string, { description?: string; type?: string }> };
|
||
if (r.notCreated) {
|
||
const firstErr = Object.values(r.notCreated)[0];
|
||
throw new Error(firstErr?.description || firstErr?.type || 'Failed to submit raw email');
|
||
}
|
||
if (methodName === 'EmailSubmission/set') {
|
||
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
|
||
emailSubmissionId = created?.id;
|
||
serverSendAt = created?.sendAt;
|
||
}
|
||
}
|
||
|
||
const submissionAccountId = this.getSubmissionAccountId();
|
||
|
||
if (delayedUntil && emailSubmissionId && !serverSendAt) {
|
||
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId, submissionAccountId);
|
||
}
|
||
|
||
return delayedUntil
|
||
? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, submissionAccountId, isSmime: true }
|
||
: { scheduled: false, emailSubmissionId, submissionAccountId, isSmime: true };
|
||
}
|
||
|
||
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; totalByAccount?: Record<string, number>; nextPosition: number }> {
|
||
// Scheduled mail composed from a shared identity lives in the shared
|
||
// account, so gate on *any* reachable submission account supporting
|
||
// delayed send rather than only the primary one.
|
||
const submissionAccountIds = this.getSubmissionAccountIds()
|
||
.filter(accountId => this.hasDelayedSend(accountId));
|
||
if (submissionAccountIds.length === 0) {
|
||
return { emails: [], hasMore: false, total: 0, nextPosition: position };
|
||
}
|
||
|
||
const now = Date.now();
|
||
const pageSize = Math.max(limit, 50);
|
||
const submissions: EmailSubmission[] = [];
|
||
// Which account each submission came from — needed to fetch its Email from
|
||
// the right account below, and to route later cancel/reschedule calls.
|
||
const accountBySubmissionId = new Map<string, string>();
|
||
const primarySubmissionAccountId = this.getSubmissionAccountId();
|
||
|
||
for (const submissionAccountId of submissionAccountIds) {
|
||
let rawPosition = 0;
|
||
let rawTotal = 0;
|
||
|
||
try {
|
||
do {
|
||
const queryResponse = await this.request([
|
||
['EmailSubmission/query', {
|
||
accountId: submissionAccountId,
|
||
limit: pageSize,
|
||
position: rawPosition,
|
||
}, '0'],
|
||
]);
|
||
|
||
const query = queryResponse.methodResponses?.[0]?.[1] as { ids?: string[]; total?: number; position?: number } | undefined;
|
||
const ids = query?.ids ?? [];
|
||
rawTotal = query?.total ?? rawPosition + ids.length;
|
||
if (ids.length === 0) break;
|
||
|
||
const submissionResponse = await this.request([
|
||
['EmailSubmission/get', {
|
||
accountId: submissionAccountId,
|
||
ids,
|
||
properties: ['id', 'emailId', 'identityId', 'threadId', 'sendAt', 'undoStatus', 'deliveryStatus'],
|
||
}, '0'],
|
||
]);
|
||
|
||
const pending = ((submissionResponse.methodResponses?.[0]?.[1]?.list ?? []) as EmailSubmission[])
|
||
.filter(submission => {
|
||
if (submission.undoStatus !== 'pending' || !submission.sendAt) return false;
|
||
const sendAtTime = new Date(submission.sendAt).getTime();
|
||
return Number.isFinite(sendAtTime) && sendAtTime > now;
|
||
});
|
||
for (const submission of pending) accountBySubmissionId.set(submission.id, submissionAccountId);
|
||
submissions.push(...pending);
|
||
|
||
rawPosition += ids.length;
|
||
} while (rawPosition < rawTotal);
|
||
} catch (error) {
|
||
// A shared account that cannot answer (revoked access, server-side
|
||
// error) must not take the user's own scheduled mail down with it;
|
||
// the primary account's failure still surfaces as before.
|
||
if (submissionAccountId === primarySubmissionAccountId) throw error;
|
||
console.error(`[getScheduledEmails] shared account ${submissionAccountId} failed:`, error);
|
||
}
|
||
}
|
||
|
||
submissions.sort((a, b) => new Date(a.sendAt || '').getTime() - new Date(b.sendAt || '').getTime());
|
||
const total = submissions.length;
|
||
// Per-account totals over ALL pending submissions, not just the page below,
|
||
// so a shared account's badge stays correct past the first page.
|
||
const totalByAccount: Record<string, number> = {};
|
||
for (const submission of submissions) {
|
||
const accountId = accountBySubmissionId.get(submission.id);
|
||
if (accountId) totalByAccount[accountId] = (totalByAccount[accountId] ?? 0) + 1;
|
||
}
|
||
const pageSubmissions = submissions.slice(position, position + limit);
|
||
const nextPosition = position + pageSubmissions.length;
|
||
|
||
if (pageSubmissions.length === 0) {
|
||
return { emails: [], hasMore: false, total, totalByAccount, nextPosition };
|
||
}
|
||
|
||
// The submission's Email lives in the same account as the submission, so a
|
||
// shared-account scheduled message must be fetched from there — asking the
|
||
// primary account returns nothing and the row would be dropped below.
|
||
const emailIdsByAccount = new Map<string, string[]>();
|
||
for (const submission of pageSubmissions) {
|
||
const accountId = accountBySubmissionId.get(submission.id) ?? this.accountId;
|
||
const bucket = emailIdsByAccount.get(accountId);
|
||
if (bucket) bucket.push(submission.emailId);
|
||
else emailIdsByAccount.set(accountId, [submission.emailId]);
|
||
}
|
||
|
||
const emailById = new Map<string, Email>();
|
||
for (const [accountId, ids] of emailIdsByAccount) {
|
||
const emailResponse = await this.request([
|
||
['Email/get', {
|
||
accountId,
|
||
ids,
|
||
properties: [
|
||
'id', 'threadId', 'mailboxIds', 'keywords', 'size', 'receivedAt', 'from', 'to', 'cc', 'bcc', 'replyTo',
|
||
'subject', 'preview', 'textBody', 'htmlBody', 'bodyValues', 'attachments', 'hasAttachment', 'sentAt',
|
||
'messageId', 'inReplyTo', 'references', 'headers', 'blobId', 'bodyStructure',
|
||
],
|
||
fetchTextBodyValues: true,
|
||
fetchHTMLBodyValues: true,
|
||
fetchAllBodyValues: true,
|
||
maxBodyValueBytes: 256000,
|
||
}, '0'],
|
||
]);
|
||
for (const email of ((emailResponse.methodResponses?.[0]?.[1]?.list ?? []) as Email[])) {
|
||
emailById.set(email.id, email);
|
||
}
|
||
}
|
||
const emails = pageSubmissions
|
||
.map((submission): ScheduledEmail | null => {
|
||
const email = emailById.get(submission.emailId);
|
||
if (!email || !submission.sendAt) return null;
|
||
return {
|
||
...email,
|
||
threadId: submission.threadId || email.threadId,
|
||
scheduledSendAt: submission.sendAt,
|
||
emailSubmissionId: submission.id,
|
||
scheduledIdentityId: submission.identityId,
|
||
scheduledUndoStatus: submission.undoStatus,
|
||
scheduledDeliveryStatus: submission.deliveryStatus,
|
||
scheduledAccountId: accountBySubmissionId.get(submission.id),
|
||
isScheduled: true,
|
||
isSmimeScheduled: isSmimeEmail(email),
|
||
};
|
||
})
|
||
.filter((email): email is ScheduledEmail => email !== null)
|
||
.sort((a, b) => new Date(a.scheduledSendAt).getTime() - new Date(b.scheduledSendAt).getTime());
|
||
|
||
return { emails, hasMore: nextPosition < total, total, totalByAccount, nextPosition };
|
||
}
|
||
|
||
async cancelEmailSubmission(submissionId: string, accountId?: string): Promise<void> {
|
||
// A submission created from a shared identity belongs to the shared
|
||
// account; cancelling it through the primary account answers `notFound`
|
||
// and the message would still go out. See #874.
|
||
const submissionAccountId = accountId ?? await this.resolveSubmissionAccountId(submissionId);
|
||
const response = await this.request([
|
||
['EmailSubmission/set', {
|
||
accountId: submissionAccountId,
|
||
update: { [submissionId]: { undoStatus: 'canceled' } },
|
||
}, '0'],
|
||
]);
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
const error = result?.notUpdated?.[submissionId];
|
||
if (error) {
|
||
throw new Error(error.description || error.type || 'Failed to cancel scheduled send');
|
||
}
|
||
}
|
||
|
||
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string, accountId?: string): Promise<SendEmailResult> {
|
||
// Keep the replacement in the same account as the original: a shared
|
||
// submission rescheduled through the primary account would be recreated
|
||
// in the wrong account (and the original left running). See #874.
|
||
const submissionAccountId = accountId ?? await this.resolveSubmissionAccountId(submissionId);
|
||
const holdForSeconds = this.validateDelayedUntil(delayedUntil, submissionAccountId);
|
||
const mailboxes = await this.getMailboxes(submissionAccountId);
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||
// The identity and the Email live beside the submission, so look both up in
|
||
// the owning account. getIdentities() only knows the primary account's
|
||
// identities; a shared identity missing there would silently put the login
|
||
// user's own address into MAIL FROM of the replacement.
|
||
let identity: Identity | undefined;
|
||
try {
|
||
const identityResponse = await this.request([
|
||
['Identity/get', { accountId: submissionAccountId, ids: [identityId], properties: ['id', 'email'] }, '0'],
|
||
]);
|
||
identity = ((identityResponse.methodResponses?.[0]?.[1]?.list ?? []) as Identity[])
|
||
.find(item => item.id === identityId);
|
||
} catch (error) {
|
||
console.error('Failed to get identity for reschedule:', error);
|
||
}
|
||
const existingEnvelope = await this.getEmailSubmissionEnvelope(submissionId, submissionAccountId);
|
||
const email = existingEnvelope?.rcptTo?.length ? undefined : await this.getEmail(emailId, submissionAccountId);
|
||
const envelopeRecipients = existingEnvelope?.rcptTo?.length
|
||
? existingEnvelope.rcptTo
|
||
: [...(email?.to || []), ...(email?.cc || []), ...(email?.bcc || [])];
|
||
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
|
||
const response = await this.request([
|
||
['EmailSubmission/set', {
|
||
accountId: submissionAccountId,
|
||
create: { replacement: { emailId, identityId, ...(envelope ? { envelope } : {}) } },
|
||
...(draftsMailbox && sentMailbox ? {
|
||
onSuccessUpdateEmail: {
|
||
'#replacement': {
|
||
...mailboxIdsReplacement(sentMailbox.id),
|
||
'keywords/$draft': null,
|
||
},
|
||
},
|
||
} : {}),
|
||
}, '0'],
|
||
]);
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
const createError = result?.notCreated?.replacement;
|
||
if (createError) {
|
||
throw new Error(createError.description || createError.type || 'Failed to reschedule email');
|
||
}
|
||
const replacementId = result?.created?.replacement?.id;
|
||
const serverSendAt = result?.created?.replacement?.sendAt;
|
||
if (!replacementId) {
|
||
throw new Error('Server did not return a replacement scheduled send ID');
|
||
}
|
||
const finalSendAt = serverSendAt || await this.getEmailSubmissionSendAt(replacementId, submissionAccountId);
|
||
try {
|
||
await this.cancelEmailSubmission(submissionId, submissionAccountId);
|
||
} catch (error) {
|
||
try {
|
||
await this.cancelEmailSubmission(replacementId, submissionAccountId);
|
||
} catch (cleanupError) {
|
||
console.error('Failed to clean up replacement scheduled send after reschedule failure:', cleanupError);
|
||
}
|
||
const message = error instanceof Error ? error.message : 'Failed to cancel original scheduled send';
|
||
throw new Error(`Reschedule created a replacement but could not cancel the original: ${message}`);
|
||
}
|
||
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
|
||
}
|
||
|
||
// The third parameter is intentionally unused: this restores an undo-send /
|
||
// canceled-scheduled message to be a draft, so it should live in Drafts *only*.
|
||
// A full mailboxIds replacement (rather than mailboxIds/<id> pointer patches)
|
||
// both drops the Sent copy without needing its id and stays safe for numeric
|
||
// mailbox ids — see mailboxIdsReplacement().
|
||
async restoreEmailToDraft(emailId: string, draftMailboxId: string, _sentMailboxId?: string): Promise<void> {
|
||
const update: Record<string, unknown> = {
|
||
...mailboxIdsReplacement(draftMailboxId),
|
||
'keywords/$draft': true,
|
||
'keywords/$seen': true,
|
||
};
|
||
const response = await this.request([
|
||
['Email/set', {
|
||
accountId: this.accountId,
|
||
update: { [emailId]: update },
|
||
}, '0'],
|
||
]);
|
||
const result = response.methodResponses?.[0]?.[1];
|
||
const error = result?.notUpdated?.[emailId];
|
||
if (error) {
|
||
throw new Error(error.description || error.type || 'Failed to restore email to drafts');
|
||
}
|
||
}
|
||
|
||
// ── PushSubscription (RFC 8620 §7.2) ──────────────────────────────
|
||
// Used by the PWA Web Push integration. The mobile app does the same dance
|
||
// through its own JMAP client - keep these in sync.
|
||
|
||
// draft-ietf-jmap-emailpush: the session advertises this when
|
||
// PushSubscription accepts a per-account `emailPush` delivery filter.
|
||
// Stalwart >= 0.16.16. Older servers reject the capability in `using` and
|
||
// the `emailPush` property outright, so every push call gates on it.
|
||
static readonly EMAIL_PUSH_CAPABILITY = 'urn:ietf:params:jmap:emailpush';
|
||
|
||
hasEmailPushCapability(): boolean {
|
||
return this.hasCapability(JMAPClient.EMAIL_PUSH_CAPABILITY);
|
||
}
|
||
|
||
private pushUsing(withEmailPush: boolean): string[] {
|
||
return withEmailPush
|
||
? ['urn:ietf:params:jmap:core', JMAPClient.EMAIL_PUSH_CAPABILITY]
|
||
: ['urn:ietf:params:jmap:core'];
|
||
}
|
||
|
||
async listPushSubscriptions(): Promise<PushSubscription[]> {
|
||
const emailPush = this.hasEmailPushCapability();
|
||
// `emailPush` is not in the server's default property set, so ask for it
|
||
// explicitly - but only where the server knows the property at all.
|
||
const args: Record<string, unknown> = { ids: null };
|
||
if (emailPush) {
|
||
args.properties = ['id', 'deviceClientId', 'verificationCode', 'expires', 'types', 'emailPush'];
|
||
}
|
||
const response = await this.request(
|
||
[['PushSubscription/get', args, '0']],
|
||
this.pushUsing(emailPush),
|
||
);
|
||
const [, body] = response.methodResponses[0] ?? [];
|
||
return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? [];
|
||
}
|
||
|
||
async createPushSubscription(params: {
|
||
deviceClientId: string;
|
||
url: string;
|
||
types: string[];
|
||
expires?: string;
|
||
emailPush?: Record<string, EmailPushConfig>;
|
||
}): Promise<string> {
|
||
const created: Record<string, unknown> = {
|
||
deviceClientId: params.deviceClientId,
|
||
url: params.url,
|
||
types: params.types,
|
||
};
|
||
if (params.expires) created.expires = params.expires;
|
||
const withEmailPush = !!params.emailPush && this.hasEmailPushCapability();
|
||
if (withEmailPush) created.emailPush = params.emailPush;
|
||
|
||
const response = await this.request(
|
||
[['PushSubscription/set', { create: { new: created } }, '0']],
|
||
this.pushUsing(withEmailPush),
|
||
);
|
||
const [, body] = response.methodResponses[0] ?? [];
|
||
const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined);
|
||
const id = result?.created?.new?.id;
|
||
if (!id) {
|
||
throw new Error(
|
||
`PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`,
|
||
);
|
||
}
|
||
return id;
|
||
}
|
||
|
||
async verifyPushSubscription(id: string, verificationCode: string): Promise<void> {
|
||
const response = await this.request(
|
||
[['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']],
|
||
['urn:ietf:params:jmap:core'],
|
||
);
|
||
const [, body] = response.methodResponses[0] ?? [];
|
||
const notUpdated = (body as { notUpdated?: Record<string, unknown> } | undefined)?.notUpdated?.[id];
|
||
if (notUpdated) {
|
||
throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`);
|
||
}
|
||
}
|
||
|
||
// Returns false when the server rejects the update (e.g. the subscription
|
||
// was already destroyed) - the caller treats that as a signal to recreate.
|
||
async updatePushSubscription(
|
||
id: string,
|
||
patch: { expires?: string; types?: string[]; emailPush?: Record<string, EmailPushConfig> | null },
|
||
): Promise<boolean> {
|
||
const withEmailPush = patch.emailPush !== undefined && this.hasEmailPushCapability();
|
||
const update: Record<string, unknown> = { ...patch };
|
||
if (!withEmailPush) delete update.emailPush;
|
||
const response = await this.request(
|
||
[['PushSubscription/set', { update: { [id]: update } }, '0']],
|
||
this.pushUsing(withEmailPush),
|
||
);
|
||
const [, body] = response.methodResponses[0] ?? [];
|
||
const r = body as { updated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
|
||
if (r?.notUpdated?.[id]) return false;
|
||
return r?.updated?.[id] !== undefined;
|
||
}
|
||
|
||
async destroyPushSubscription(id: string): Promise<void> {
|
||
await this.request(
|
||
[['PushSubscription/set', { destroy: [id] }, '0']],
|
||
['urn:ietf:params:jmap:core'],
|
||
);
|
||
}
|
||
}
|