mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
Folder management performed on a shared/group account was sent to the logged-in user's primary JMAP account instead of the mailbox owner's account. Subfolder creation therefore failed with invalidProperties for parentId, root folders could be created silently in the personal account, and rename, delete, role, and reorder targeted the wrong mailbox tree. Shared mailboxes use namespaced store ids to avoid cross-account collisions, while Mailbox/set requires the owner's accountId and the bare server mailbox id. Resolve that mutation context centrally: recover the mailbox originalId and owner account, use a directly connected owner client when one exists, otherwise reach delegated/group accounts through the active client with an explicit accountId. Thread the account target through create, update, and delete JMAP methods and all folder-management actions. Scope role replacement to the same owner account, and expose explicit root-folder creation from shared account headers without making personal root creation depend on the currently selected folder. Add unit coverage for JMAP request scoping, shared store routing, role isolation, direct-client handling, and reorder. Add a Stalwart integration scenario that creates root and nested group folders, renames them, and deletes them through the UI.
369 lines
21 KiB
TypeScript
369 lines
21 KiB
TypeScript
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
|
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
|
|
|
/**
|
|
* Interface defining the public JMAP client contract.
|
|
*
|
|
* Both the real `JMAPClient` (network-backed) and `DemoJMAPClient`
|
|
* (in-memory/browser-only) implement this interface so that stores
|
|
* and UI code never need to know which one is active.
|
|
*/
|
|
export interface IJMAPClient {
|
|
// ── Connection lifecycle ──────────────────────────────────────
|
|
connect(): Promise<void>;
|
|
disconnect(): void;
|
|
reconnect(): Promise<void>;
|
|
ping(): Promise<void>;
|
|
|
|
// ── Session / auth accessors ──────────────────────────────────
|
|
getServerUrl(): string;
|
|
getAuthHeader(): string;
|
|
updateAccessToken(token: string): void;
|
|
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void;
|
|
enableTotpReauth(basePassword: string, callback: () => Promise<string | null>): void;
|
|
updateBasicAuth(newPassword: string): void;
|
|
getAccountId(): string;
|
|
getUsername(): string;
|
|
|
|
// ── Capabilities ──────────────────────────────────────────────
|
|
getCapabilities(): Record<string, unknown>;
|
|
hasAccountCapability(capability: string, accountId?: string): boolean;
|
|
getMaxSizeUpload(): number;
|
|
getMaxCallsInRequest(): number;
|
|
getMaxObjectsInGet(): number;
|
|
getMaxObjectsInSet(): number;
|
|
getMaxDelayedSend(accountId?: string): number;
|
|
hasDelayedSend(accountId?: string): boolean;
|
|
getEventSourceUrl(): string | null;
|
|
supportsEmailSubmission(): boolean;
|
|
supportsQuota(): boolean;
|
|
supportsVacationResponse(): boolean;
|
|
supportsContacts(): boolean;
|
|
supportsCalendars(): boolean;
|
|
supportsSieve(): boolean;
|
|
supportsFiles(accountId?: string): boolean;
|
|
|
|
// ── Push / state ──────────────────────────────────────────────
|
|
setupPushNotifications(): boolean;
|
|
closePushNotifications(): void;
|
|
onConnectionChange(callback: (connected: boolean) => void): void;
|
|
onRateLimit(callback: (rateLimited: boolean, retryAfterMs: number) => void): void;
|
|
isRateLimited(): boolean;
|
|
getRateLimitRemainingMs(): number;
|
|
onStateChange(callback: (change: StateChange) => void): void;
|
|
getLastStates(): AccountStates;
|
|
setLastStates(states: AccountStates): void;
|
|
|
|
// ── PushSubscription (RFC 8620 §7.2) ───────────────────────────
|
|
// Browser-driven Web Push setup: register a relay URL the JMAP server can
|
|
// forward StateChange events to. Mobile uses the same primitives.
|
|
listPushSubscriptions(): Promise<PushSubscription[]>;
|
|
createPushSubscription(params: {
|
|
deviceClientId: string;
|
|
url: string;
|
|
types: string[];
|
|
expires?: string;
|
|
}): Promise<string>;
|
|
verifyPushSubscription(id: string, verificationCode: string): Promise<void>;
|
|
updatePushSubscription(id: string, patch: { expires?: string; types?: string[] }): Promise<boolean>;
|
|
destroyPushSubscription(id: string): Promise<void>;
|
|
|
|
// ── Quota ─────────────────────────────────────────────────────
|
|
getQuota(): Promise<{ used: number; total: number } | null>;
|
|
|
|
// ── Mailboxes ─────────────────────────────────────────────────
|
|
getMailboxes(accountId?: string): Promise<Mailbox[]>;
|
|
getAllMailboxes(): Promise<Mailbox[]>;
|
|
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
|
|
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }, accountId?: string): Promise<void>;
|
|
deleteMailbox(mailboxId: string, accountId?: string): Promise<void>;
|
|
|
|
// ── Emails ────────────────────────────────────────────────────
|
|
// `pinnedFirst` sorts emails carrying the $pinned keyword to the top
|
|
// (server-side hasKeyword sort comparator, RFC 8621), then receivedAt desc.
|
|
// `extraFilter` is an arbitrary JMAP FilterCondition/FilterOperator ANDed
|
|
// into the view - used by the message-list category tabs (search-based).
|
|
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
|
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
|
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
|
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
|
|
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
|
/**
|
|
* Every keyword currently set on the account's messages and how many of the
|
|
* walked messages carry it, found by walking the message list - JMAP offers no
|
|
* way to ask for the keywords in use. `complete` is false when `limit` (or an
|
|
* abort) ended the walk early, which also makes every count a floor.
|
|
*/
|
|
discoverKeywords(options?: {
|
|
limit?: number;
|
|
onProgress?: (scanned: number, total: number) => void;
|
|
signal?: AbortSignal;
|
|
}): Promise<{ keywords: Record<string, number>; scanned: number; total: number; complete: boolean }>;
|
|
/** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */
|
|
getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, accountId?: string): Promise<Record<string, number>>;
|
|
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
|
advancedSearchEmails(
|
|
filter: Record<string, unknown>,
|
|
accountId?: string,
|
|
limit?: number,
|
|
position?: number,
|
|
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
|
/**
|
|
* Lean recipient search for compose autocomplete ("search the server" action):
|
|
* finds messages in `sentMailboxId` whose to/cc matches `query` and returns
|
|
* only the matching addresses (fetches just the `to`/`cc` properties - no
|
|
* bodies or attachments), deduped.
|
|
*/
|
|
searchSentRecipients(query: string, sentMailboxId: string, accountId?: string, limit?: number): Promise<Array<{ name: string; email: string }>>;
|
|
|
|
// ── Email mutations ───────────────────────────────────────────
|
|
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
|
|
batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise<void>;
|
|
toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void>;
|
|
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>, accountId?: string): Promise<void>;
|
|
setKeyword(emailId: string, keyword: string, accountId?: string): Promise<void>;
|
|
removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void>;
|
|
/** Apply one `keywords/<name>` patch fragment (true=add, null=remove) to many messages in a single Email/set. */
|
|
batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void>;
|
|
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
|
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
|
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
|
batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void>;
|
|
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
|
batchArchiveEmails(
|
|
emails: Array<{ id: string; receivedAt: string }>,
|
|
archiveMailboxId: string,
|
|
mode: 'single' | 'year' | 'month',
|
|
existingMailboxes: Mailbox[],
|
|
accountId?: string,
|
|
): Promise<void>;
|
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
|
emptyMailbox(mailboxId: string, accountId?: string): Promise<number>;
|
|
markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number>;
|
|
markAllAsRead(excludeMailboxIds?: string[], accountId?: string): Promise<number>;
|
|
markAsSpam(emailId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
|
undoSpam(emailId: string, originalMailboxId: string, accountId?: string): Promise<void>;
|
|
|
|
// ── Threads ───────────────────────────────────────────────────
|
|
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
|
|
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
|
|
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
|
|
|
|
// ── Compose / Send ────────────────────────────────────────────
|
|
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>;
|
|
|
|
sendEmail(
|
|
to: string[],
|
|
subject: string,
|
|
body: string,
|
|
cc?: string[],
|
|
bcc?: string[],
|
|
identityId?: string,
|
|
fromEmail?: string,
|
|
draftId?: string,
|
|
fromName?: string,
|
|
htmlBody?: string,
|
|
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
|
|
inReplyTo?: string[],
|
|
references?: string[],
|
|
delayedUntil?: string,
|
|
envelopeMailFrom?: string,
|
|
options?: { requestReadReceipt?: boolean },
|
|
): Promise<SendEmailResult>;
|
|
|
|
importEmail(
|
|
blobId: string,
|
|
mailboxIds: Record<string, boolean>,
|
|
keywords?: Record<string, boolean>,
|
|
accountId?: string,
|
|
): Promise<string | null>;
|
|
|
|
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>;
|
|
|
|
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
|
submitRawEmail(blob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
|
|
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
|
|
cancelEmailSubmission(submissionId: string): Promise<void>;
|
|
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
|
|
/** `sentMailboxId` is accepted for backwards compatibility but ignored: the message is placed in Drafts only. */
|
|
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
|
|
|
|
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>;
|
|
|
|
sendImipInvitation(event: CalendarEvent): Promise<void>;
|
|
sendImipCancellation(event: CalendarEvent): Promise<void>;
|
|
|
|
// ── Blobs ─────────────────────────────────────────────────────
|
|
uploadBlob(
|
|
file: File,
|
|
optsOrAccountId?:
|
|
| string
|
|
| {
|
|
accountId?: string;
|
|
onProgress?: (loaded: number, total: number) => void;
|
|
signal?: AbortSignal;
|
|
},
|
|
): Promise<{ blobId: string; size: number; type: string }>;
|
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
|
|
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
|
|
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string>;
|
|
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer>;
|
|
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
|
|
|
|
// ── Identities ────────────────────────────────────────────────
|
|
getIdentities(): Promise<Identity[]>;
|
|
createIdentity(
|
|
name: string,
|
|
email: string,
|
|
replyTo?: EmailAddress[] | null,
|
|
bcc?: EmailAddress[] | null,
|
|
textSignature?: string | null,
|
|
htmlSignature?: string | null,
|
|
): Promise<Identity>;
|
|
updateIdentity(
|
|
identityId: string,
|
|
updates: {
|
|
name?: string | null;
|
|
replyTo?: EmailAddress[] | null;
|
|
bcc?: EmailAddress[] | null;
|
|
textSignature?: string | null;
|
|
htmlSignature?: string | null;
|
|
},
|
|
): Promise<void>;
|
|
deleteIdentity(identityId: string): Promise<void>;
|
|
|
|
// ── Vacation ──────────────────────────────────────────────────
|
|
getVacationResponse(accountId?: string): Promise<VacationResponse>;
|
|
setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void>;
|
|
|
|
// ── Contacts ──────────────────────────────────────────────────
|
|
getContactsAccountId(): string;
|
|
getAddressBooks(options?: { throwOnError?: boolean }): Promise<AddressBook[]>;
|
|
getAllAddressBooks(): Promise<AddressBook[]>;
|
|
createAddressBook(name: string): Promise<AddressBook>;
|
|
updateAddressBook(addressBookId: string, updates: Partial<AddressBook>, targetAccountId?: string): Promise<void>;
|
|
deleteAddressBook(addressBookId: string, targetAccountId?: string): Promise<void>;
|
|
getContacts(addressBookId?: string, options?: { throwOnError?: boolean }): Promise<ContactCard[]>;
|
|
getAllContacts(): Promise<ContactCard[]>;
|
|
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
|
createContact(contact: Partial<ContactCard>, targetAccountId?: string): Promise<ContactCard>;
|
|
updateContact(contactId: string, updates: Partial<ContactCard>, targetAccountId?: string): Promise<void>;
|
|
deleteContact(contactId: string, targetAccountId?: string): Promise<void>;
|
|
searchContacts(query: string): Promise<ContactCard[]>;
|
|
|
|
// ── Calendars ─────────────────────────────────────────────────
|
|
getCalendarsAccountId(): string;
|
|
getCalendars(): Promise<Calendar[]>;
|
|
getAllCalendars(): Promise<Calendar[]>;
|
|
createCalendar(calendar: Partial<Calendar>, targetAccountId?: string): Promise<Calendar>;
|
|
updateCalendar(calendarId: string, updates: Partial<Calendar>, targetAccountId?: string): Promise<void>;
|
|
setDefaultCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
|
|
deleteCalendar(calendarId: string, targetAccountId?: string): Promise<void>;
|
|
getCalendarEvents(calendarIds?: string[], targetAccountId?: string): Promise<CalendarEvent[]>;
|
|
getCalendarEvent(id: string, targetAccountId?: string): Promise<CalendarEvent | null>;
|
|
createCalendarEvent(event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<CalendarEvent>;
|
|
batchCreateCalendarEvents(events: Partial<CalendarEvent>[], targetAccountId?: string): Promise<{ created: CalendarEvent[]; failed: string[] }>;
|
|
updateCalendarEvent(
|
|
eventId: string,
|
|
updates: Partial<CalendarEvent>,
|
|
sendSchedulingMessages?: boolean,
|
|
targetAccountId?: string,
|
|
): Promise<void>;
|
|
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
|
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
|
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
|
|
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>;
|
|
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
|
|
|
|
// ── Calendar Tasks ────────────────────────────────────────────
|
|
getCalendarTasks(calendarIds?: string[], targetAccountId?: string): Promise<CalendarTask[]>;
|
|
createCalendarTask(task: Partial<CalendarTask>, targetAccountId?: string): Promise<CalendarTask>;
|
|
updateCalendarTask(taskId: string, updates: Partial<CalendarTask>, targetAccountId?: string): Promise<void>;
|
|
deleteCalendarTask(taskId: string, targetAccountId?: string): Promise<void>;
|
|
|
|
// ── Sharing (RFC 9670 Principals) ─────────────────────────────
|
|
supportsPrincipals(): boolean;
|
|
getPrincipals(targetAccountId?: string): Promise<Principal[]>;
|
|
setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void>;
|
|
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
|
|
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
|
|
|
|
// ── Accounts (primary + shared/group) ────────────────────────
|
|
getSharedAccounts(): SharedAccount[];
|
|
|
|
// ── Sieve / Filters ──────────────────────────────────────────
|
|
getSieveAccountId(): string;
|
|
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[];
|
|
getSieveCapabilities(accountId?: string): SieveCapabilities | null;
|
|
getSieveScripts(accountId?: string): Promise<SieveScript[]>;
|
|
getSieveScriptContent(blobId: string, accountId?: string): Promise<string>;
|
|
createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript>;
|
|
updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void>;
|
|
deleteSieveScript(scriptId: string, accountId?: string): Promise<void>;
|
|
validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }>;
|
|
|
|
// ── Files (WebDAV / FileNode) ─────────────────────────────────
|
|
getFilesAccountId(): string;
|
|
probeFileNodeSupport(): Promise<boolean>;
|
|
listFileNodes(parentId: string | null): Promise<FileNode[]>;
|
|
listAllFileNodes(): Promise<FileNode[]>;
|
|
listAllFileNodesAcrossAccounts(): Promise<FileNode[]>;
|
|
getFileNodes(ids: string[] | null, properties?: string[]): Promise<FileNode[]>;
|
|
createFileDirectory(name: string, parentId: string | null): Promise<FileNode>;
|
|
createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode>;
|
|
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
|
|
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
|
|
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
|
|
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
|
|
|
// ── S/MIME raw-email helpers ──────────────────────────────────
|
|
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
|
submitEmail(emailId: string, identityId: string): Promise<void>;
|
|
/**
|
|
* Server-side move of one email across accounts reachable through THIS client
|
|
* (JMAP `Email/copy` + destroy-original). Used for delegated/shared folders,
|
|
* where the two accounts share a client but a client can't stage a blob in a
|
|
* delegated account (so the blob copy+import path doesn't work). Returns the
|
|
* new email id in the destination account.
|
|
*/
|
|
copyEmailAcrossAccounts(emailId: string, fromAccountId: string, toAccountId: string, destMailboxId: string): Promise<string>;
|
|
}
|