feat: add JMAP sharing for calendars and address books
This commit is contained in:
@@ -82,7 +82,7 @@ function makeCalendar(overrides: Partial<Calendar> = {}): Calendar {
|
||||
mayWriteOwn: true,
|
||||
mayUpdatePrivate: true,
|
||||
mayRSVP: true,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
...overrides,
|
||||
|
||||
@@ -30,7 +30,7 @@ export function createBirthdayCalendar(name?: string, color?: string): Calendar
|
||||
mayWriteOwn: false,
|
||||
mayUpdatePrivate: false,
|
||||
mayRSVP: false,
|
||||
mayAdmin: false,
|
||||
mayShare: false,
|
||||
mayDelete: false,
|
||||
},
|
||||
};
|
||||
|
||||
+10
-1
@@ -78,6 +78,10 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
supportsCalendars(): boolean { return true; }
|
||||
supportsSieve(): boolean { return true; }
|
||||
supportsFiles(): boolean { return true; }
|
||||
supportsPrincipals(): boolean { return false; }
|
||||
async getPrincipals(): Promise<never[]> { return []; }
|
||||
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
|
||||
async setAddressBookShare(): Promise<void> { /* demo: no-op */ }
|
||||
|
||||
// ── Push / state ──────────────────────────────────────────────
|
||||
|
||||
@@ -552,6 +556,11 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
if (book) Object.assign(book, updates);
|
||||
}
|
||||
|
||||
async deleteAddressBook(addressBookId: string): Promise<void> {
|
||||
this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId);
|
||||
this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]);
|
||||
}
|
||||
|
||||
async getContacts(addressBookId?: string): Promise<ContactCard[]> {
|
||||
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
||||
return [...this.data.contacts];
|
||||
@@ -609,7 +618,7 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
includeInAvailability: 'all',
|
||||
defaultAlertsWithTime: null, defaultAlertsWithoutTime: null,
|
||||
timeZone: null, shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
...calendar,
|
||||
} as Calendar;
|
||||
this.data.calendars.push(full);
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: false },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-work',
|
||||
@@ -33,7 +33,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
},
|
||||
{
|
||||
id: 'demo-calendar-birthdays',
|
||||
@@ -49,7 +49,7 @@ export function createDemoCalendars(): Calendar[] {
|
||||
defaultAlertsWithoutTime: null,
|
||||
timeZone: null,
|
||||
shareWith: null,
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayAdmin: true, mayDelete: true },
|
||||
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
/**
|
||||
@@ -190,6 +190,7 @@ export interface IJMAPClient {
|
||||
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): Promise<ContactCard[]>;
|
||||
getAllContacts(): Promise<ContactCard[]>;
|
||||
getContact(contactId: string, accountId?: string): Promise<ContactCard | null>;
|
||||
@@ -227,6 +228,12 @@ export interface IJMAPClient {
|
||||
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>;
|
||||
|
||||
// ── Sieve / Filters ──────────────────────────────────────────
|
||||
getSieveAccountId(): string;
|
||||
getSieveCapabilities(): SieveCapabilities | null;
|
||||
|
||||
+101
-1
@@ -1,4 +1,4 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter } from "./types";
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
@@ -2826,6 +2826,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
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;
|
||||
@@ -3198,6 +3202,102 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error("Failed to update 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchPaginatedContacts(
|
||||
accountId: string,
|
||||
filter?: Record<string, unknown>,
|
||||
|
||||
+15
-2
@@ -368,6 +368,7 @@ export interface AddressBook {
|
||||
isDefault?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
myRights?: AddressBookRights;
|
||||
shareWith?: Record<string, AddressBookRights> | null;
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
isShared?: boolean;
|
||||
@@ -376,10 +377,22 @@ export interface AddressBook {
|
||||
export interface AddressBookRights {
|
||||
mayRead: boolean;
|
||||
mayWrite: boolean;
|
||||
mayShare: boolean;
|
||||
mayShare?: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
// JMAP Principals (RFC 9670)
|
||||
export interface Principal {
|
||||
id: string;
|
||||
type: 'individual' | 'group' | 'resource' | 'location' | 'other';
|
||||
name: string;
|
||||
description?: string | null;
|
||||
email?: string | null;
|
||||
timeZone?: string | null;
|
||||
capabilities?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
export interface VacationResponse {
|
||||
id: string;
|
||||
isEnabled: boolean;
|
||||
@@ -442,7 +455,7 @@ export interface CalendarRights {
|
||||
mayWriteOwn: boolean;
|
||||
mayUpdatePrivate: boolean;
|
||||
mayRSVP: boolean;
|
||||
mayAdmin: boolean;
|
||||
mayShare: boolean;
|
||||
mayDelete: boolean;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user