From dae45e12033e195028ce371ea83ac17eaf4265ea Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:32:02 +0100 Subject: [PATCH] feat: give the demo client principals and sharing --- lib/demo/__tests__/fixtures.test.ts | 45 +++++++++- lib/demo/demo-client.ts | 126 ++++++++++++++++++++++++++-- lib/demo/demo-data.ts | 8 +- lib/demo/fixtures/directory.ts | 68 +++++++++++++++ 4 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 lib/demo/fixtures/directory.ts diff --git a/lib/demo/__tests__/fixtures.test.ts b/lib/demo/__tests__/fixtures.test.ts index 093ead4f9..aca15c936 100644 --- a/lib/demo/__tests__/fixtures.test.ts +++ b/lib/demo/__tests__/fixtures.test.ts @@ -12,7 +12,7 @@ import path from 'node:path'; import { getDemoData } from '../demo-data'; import { DemoJMAPClient } from '../demo-client'; import { DEMO_ASSETS } from '../fixtures/assets'; -import { PORTRAITS, NORA, NORA_PERSONAL, ALICE, BOB } from '../fixtures/people'; +import { PORTRAITS, NORA, NORA_PERSONAL, ALICE, BOB, PRIYA } from '../fixtures/people'; import { GONOGO_INVITE_BLOB, GONOGO_UID } from '../fixtures/emails'; import { GONOGO_EVENT_ID, createGoNoGoInvitationIcs } from '../fixtures/calendars'; import { SUPPORT_ACCOUNT_ID, SMB, MB } from '../fixtures/mailboxes'; @@ -23,6 +23,10 @@ import { findCalendarAttachment } from '@/lib/calendar-invitation'; import { mentionFromLink } from '@/lib/recipient-mentions'; import { KEYWORD_PALETTE, useSettingsStore } from '@/stores/settings-store'; import { seedDemoPreferences, DEMO_SEED_KEY } from '../demo-preferences'; +import { PRINCIPAL } from '../fixtures/directory'; +import { confirmGroups } from '@/lib/plugin-sandbox/account-kind'; +import { summarizeDelegates } from '@/lib/delegation/delegation'; +import { MAILBOX_PRESETS } from '@/lib/jmap/share-rights'; const PUBLIC = path.resolve(__dirname, '..', '..', '..', 'public'); const work = getDemoData('work'); @@ -346,4 +350,43 @@ describe('demo fixtures: audience screenshots', () => { expect(to).toBeLessThanOrEqual(new Date(trip.utcEnd!).getTime()); expect(reply.textBody).toContain('Lisbon'); }); + + it('the server confirms the Support mailbox as a group, the way plugins and shared tags ask', async () => { + const client = new DemoJMAPClient('work'); + expect(client.supportsPrincipals()).toBe(true); + expect(await client.getPrincipalTypes([SUPPORT_ACCOUNT_ID, PRINCIPAL.alice])).toEqual({ [SUPPORT_ACCOUNT_ID]: 'group', [PRINCIPAL.alice]: 'individual' }); + expect([...await confirmGroups(client)]).toEqual([SUPPORT_ACCOUNT_ID]); + // Nora's principal is her own account, so she is never her own delegate. + expect(PRINCIPAL.nora).toBe(client.getAccountId()); + }); + + it('the directory lists the Meridian colleagues and finds them by address', async () => { + const client = new DemoJMAPClient('work'); + const { principals, status } = await client.listPrincipals(); + expect(status).toBe('ok'); + for (const person of [ALICE, BOB, PRIYA]) expect(principals.some((p) => p.email === person.email && p.type === 'individual'), person.name).toBe(true); + expect((await client.findPrincipalByEmail(` ${ALICE.email.toUpperCase()} `)).principal?.id).toBe(PRINCIPAL.alice); + expect((await client.findPrincipalByEmail('tomas.ferreira@lumenlogistics.example')).principal).toBeNull(); + expect(new DemoJMAPClient('personal').supportsPrincipals()).toBe(false); + }); + + it('Settings > Sharing lists Alice with read access to the Inbox, and a new grant sticks', async () => { + const client = new DemoJMAPClient('work'); + // The gate in components/settings/settings-app.tsx. + expect(!!client.getShareTargets && client.supportsPrincipals() && client.supportsMailboxSharing()).toBe(true); + const { targets, failed } = await client.getShareTargets(); + expect(failed).toEqual([]); + const [alice, ...others] = summarizeDelegates(targets, [client.getAccountId()]); + expect(others).toEqual([]); + expect(alice.principalId).toBe(PRINCIPAL.alice); + expect(alice.level).toBe('read'); + expect(alice.mail).toMatchObject({ shared: 1, inbox: true }); + + const sent = targets.find((t) => t.kind === 'mailbox' && t.role === 'sent')!; + const [result] = await client.applyShareChanges([{ kind: 'mailbox', accountId: sent.accountId, id: sent.id, name: sent.name, principalId: PRINCIPAL.bob, rights: MAILBOX_PRESETS.read }]); + expect(result.ok).toBe(true); + expect(await client.getMailboxShareWith(sent.id)).toEqual({ [PRINCIPAL.bob]: MAILBOX_PRESETS.read }); + await client.setMailboxShare(sent.id, PRINCIPAL.bob, null); + expect(await client.getMailboxShareWith(sent.id)).toBeNull(); + }); }); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 9d3139013..5d878fe44 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1,6 +1,9 @@ import type { IJMAPClient, KeywordDiscoveryResult, KeywordMigration } from '@/lib/jmap/client-interface'; -import type { AccountInfo, Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types'; +import type { AccountInfo, Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarRights, CalendarTask, FileNode, FileNodeRights, MailboxRights, Principal, PrincipalListing, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types'; import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; +import type { ShareChange, ShareChangeResult, ShareTarget } from '@/lib/jmap/share-batch'; +import type { AnyRights, ShareKind } from '@/lib/jmap/share-rights'; +import { createDemoPrincipals } from './fixtures/directory'; import { getDemoData, demoSharedAccounts, type DemoData, type DemoProfile } from './demo-data'; import { generateDemoId } from './demo-utils'; import { DEMO_ASSETS } from './fixtures/assets'; @@ -43,6 +46,8 @@ export const DEMO_SIMULATE_KEY = 'bulwark-demo-simulate'; export class DemoJMAPClient implements IJMAPClient { private data: DemoData; private readonly profile: DemoProfile; + /** The server's directory: the Meridian Labs cast for the work login, nobody for the personal one. */ + private readonly principals: Principal[]; private blobStore = new Map(); private scheduledSubmissions = new Map(); private connectionCallback: ((connected: boolean) => void) | null = null; @@ -53,6 +58,7 @@ export class DemoJMAPClient implements IJMAPClient { constructor(profile: DemoProfile = 'work') { this.profile = profile; this.data = getDemoData(profile); + this.principals = profile === 'personal' ? [] : createDemoPrincipals(); // Fixtures carry raw headers the way a server sends them; derive the // parsed fields the viewer reads, as JMAPClient.parseEmailHeaders does. for (const email of this.data.emails) { @@ -147,6 +153,8 @@ export class DemoJMAPClient implements IJMAPClient { 'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:sieve': {}, 'urn:ietf:params:jmap:files': {}, + 'urn:ietf:params:jmap:principals': {}, + 'urn:ietf:params:jmap:mail:share': {}, }), }; } @@ -166,10 +174,114 @@ export class DemoJMAPClient implements IJMAPClient { supportsCalendars(): boolean { return !this.isPersonal; } supportsSieve(): boolean { return !this.isPersonal; } supportsFiles(): boolean { return !this.isPersonal; } - supportsPrincipals(): boolean { return false; } - async getPrincipals(): Promise { return []; } - async setCalendarShare(): Promise { /* demo: no-op */ } - async setAddressBookShare(): Promise { /* demo: no-op */ } + + // ── Directory and sharing (RFC 9670 principals, mail:share) ─── + // The work login's server has a directory (fixtures/directory.ts) and + // keeps `shareWith` on Nora's folders, calendars, address books and files. + // The personal login's server has neither. + + supportsPrincipals(): boolean { return !this.isPersonal; } + + async getPrincipals(): Promise { + return this.principals.map((p) => ({ ...p })); + } + + async listPrincipals(): Promise { + return { principals: await this.getPrincipals(), status: 'ok' }; + } + + /** "individual" or "group" by id, as Principal/get answers; Support is a group. */ + async getPrincipalTypes(ids: string[]): Promise | null> { + if (!this.supportsPrincipals()) return ids.length === 0 ? {} : null; + const wanted = new Set(ids); + return Object.fromEntries(this.principals.filter((p) => wanted.has(p.id)).map((p) => [p.id, p.type])); + } + + async findPrincipalByEmail(email: string): Promise<{ principal: Principal | null; status: 'ok' | 'forbidden' | 'error' }> { + if (!this.supportsPrincipals()) return { principal: null, status: 'error' }; + const wanted = email.trim().toLowerCase(); + const principal = this.principals.find((p) => p.email?.toLowerCase() === wanted || p.name.toLowerCase() === wanted); + return { principal: principal ? { ...principal } : null, status: 'ok' }; + } + + supportsMailboxSharing(accountId?: string): boolean { + return this.supportsPrincipals() && (!accountId || accountId === this.data.accountId); + } + + async getMailboxShareWith(mailboxId: string, accountId?: string): Promise | null> { + const mailbox = this.ownShareItem('mailbox', mailboxId, accountId); + if (!mailbox) throw new Error('Folder not found'); + return mailbox.shareWith ? structuredClone(mailbox.shareWith) as Record : null; + } + + async setMailboxShare(mailboxId: string, principalId: string, rights: MailboxRights | null, accountId?: string): Promise { + this.setShare('mailbox', mailboxId, principalId, rights, accountId); + } + + async setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise { + this.setShare('calendar', calendarId, principalId, rights, targetAccountId); + } + + async setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise { + this.setShare('addressBook', addressBookId, principalId, rights, targetAccountId); + } + + /** Every folder, calendar and address book of Nora's own account, and the files she shared. */ + async getShareTargets(include: { calendars?: boolean; contacts?: boolean; files?: boolean } = {}): Promise<{ targets: ShareTarget[]; failed: ShareKind[] }> { + if (!this.supportsPrincipals()) return { targets: [], failed: [] }; + const accountId = this.data.accountId; + const grants = (shareWith: Record | null | undefined) => structuredClone(shareWith ?? {}); + const targets: ShareTarget[] = this.data.mailboxes.map((mb) => ({ + kind: 'mailbox', accountId, id: mb.id, name: mb.name, role: mb.role ?? null, parentId: mb.parentId ?? null, shareWith: grants(mb.shareWith), + })); + if (include.calendars !== false && this.supportsCalendars()) { + for (const cal of this.data.calendars.filter((c) => !c.isShared)) { + targets.push({ kind: 'calendar', accountId, id: cal.id, name: cal.name, isDefault: cal.isDefault, shareWith: grants(cal.shareWith) }); + } + } + if (include.contacts !== false && this.supportsContacts()) { + for (const book of this.data.addressBooks.filter((b) => !b.isShared)) { + targets.push({ kind: 'addressBook', accountId, id: book.id, name: book.name, isDefault: book.isDefault, shareWith: grants(book.shareWith) }); + } + } + if (include.files !== false && this.supportsFiles()) { + for (const node of this.data.fileNodes.filter((n) => Object.keys(n.shareWith ?? {}).length > 0)) { + targets.push({ kind: 'file', accountId, id: node.id, name: node.name, parentId: node.parentId ?? null, shareWith: grants(node.shareWith) }); + } + } + return { targets, failed: [] }; + } + + /** Grants and revokes, each with a result, as the server's /set answers would give them. */ + async applyShareChanges(changes: ShareChange[]): Promise { + return changes.map((change) => { + if (!this.ownShareItem(change.kind, change.id, change.accountId)) return { change, ok: false, error: 'notFound' }; + this.setShare(change.kind, change.id, change.principalId, change.rights, change.accountId); + return { change, ok: true }; + }); + } + + /** One of Nora's own shareable items, or null (another account's, or unknown). */ + private ownShareItem(kind: ShareKind, id: string, accountId?: string): { shareWith?: Record | null } | null { + if (!this.supportsPrincipals() || (accountId && accountId !== this.data.accountId)) return null; + const own = (list: T[]) => + list.find((item) => !item.isShared && (item.id === id || item.originalId === id)) ?? null; + switch (kind) { + case 'mailbox': return own(this.data.mailboxes); + case 'calendar': return own(this.data.calendars); + case 'addressBook': return own(this.data.addressBooks); + case 'file': return this.data.fileNodes.find((n) => n.id === id) ?? null; + } + } + + private setShare(kind: ShareKind, id: string, principalId: string, rights: AnyRights | null, accountId?: string): void { + const item = this.ownShareItem(kind, id, accountId); + if (!item) throw new Error('Not found'); + const next = { ...(item.shareWith ?? {}) }; + if (rights) next[principalId] = { ...rights }; + else delete next[principalId]; + item.shareWith = Object.keys(next).length > 0 ? next : null; + } // ── Push / state ────────────────────────────────────────────── @@ -1412,7 +1524,9 @@ export class DemoJMAPClient implements IJMAPClient { return [...this.data.fileNodes]; } - async setFileNodeShare(): Promise { /* demo: no-op */ } + async setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise { + this.setShare('file', fileNodeId, principalId, rights, targetAccountId); + } async getFileNodes(ids: string[] | null): Promise { if (ids === null) return [...this.data.fileNodes]; diff --git a/lib/demo/demo-data.ts b/lib/demo/demo-data.ts index 051c858a0..3fe6e3e5f 100644 --- a/lib/demo/demo-data.ts +++ b/lib/demo/demo-data.ts @@ -11,6 +11,7 @@ import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveCon import { createDemoFileNodes } from './fixtures/files'; import { createDemoVacationResponse } from './fixtures/vacation'; import { NORA, NORA_PERSONAL } from './fixtures/people'; +import { createDemoMailboxShares } from './fixtures/directory'; import type { Email, EmailAddress, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, CalendarTask, Identity, VacationResponse, FileNode, SharedAccount } from '@/lib/jmap/types'; import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; @@ -64,6 +65,11 @@ export function demoSharedAccounts(profile: DemoProfile): SharedAccount[] { ]; } +/** The folders with the grants Nora gave on them (fixtures/directory.ts). */ +function withShares(mailboxes: Mailbox[], shares: Record): Mailbox[] { + return mailboxes.map((mb) => (shares[mb.id] ? { ...mb, shareWith: shares[mb.id] } : mb)); +} + /** Return a fresh deep-cloned copy of all demo data for one profile. */ export function getDemoData(profile: DemoProfile = 'work'): DemoData { if (profile === 'personal') { @@ -98,7 +104,7 @@ export function getDemoData(profile: DemoProfile = 'work'): DemoData { accountId: DEMO_WORK_ACCOUNT_ID, username: NORA.email, self: { name: NORA.name, email: NORA.email }, - mailboxes: createDemoMailboxes(), + mailboxes: withShares(createDemoMailboxes(), createDemoMailboxShares()), sharedMailboxes: { [SUPPORT_ACCOUNT_ID]: { name: SUPPORT_ACCOUNT_NAME, mailboxes: createDemoSupportMailboxes() } }, emails: [...createDemoEmails(), ...createDemoSupportEmails()], contacts: createDemoContacts(), diff --git a/lib/demo/fixtures/directory.ts b/lib/demo/fixtures/directory.ts new file mode 100644 index 000000000..7ff4359f3 --- /dev/null +++ b/lib/demo/fixtures/directory.ts @@ -0,0 +1,68 @@ +/** + * The Meridian Labs directory on the demo's server: a principal for every + * colleague in the cast and one group, Support, whose mailbox is delegated + * to Nora (fixtures/support.ts). Stalwart uses an account's id as its + * principal id, so Nora's principal is her work account and the Support + * group's is the shared Support account. People outside the company + * (customers, friends) are no principals here. + * + * Also what Nora has shared with whom, as `shareWith` grants the Delegates + * page (Settings > Sharing) lists: Alice can read her Inbox while Nora is + * away. + */ +import type { MailboxRights, Principal } from '@/lib/jmap/types'; +import { MAILBOX_PRESETS } from '@/lib/jmap/share-rights'; +import { MB, SUPPORT_ACCOUNT_ID } from './mailboxes'; +import { + type Person, + NORA, ALICE, BOB, PRIYA, AHMED, JAMES, MICHAEL, RACHEL, WEI, MARIA, LISA, SUPPORT, +} from './people'; + +/** Principal ids. Nora's is the work login's account id (demo-data.ts DEMO_WORK_ACCOUNT_ID). */ +export const PRINCIPAL = { + nora: 'demo-account', + alice: 'demo-alice', + bob: 'demo-bob', + priya: 'demo-priya', + ahmed: 'demo-ahmed', + james: 'demo-james', + michael: 'demo-michael', + rachel: 'demo-rachel', + wei: 'demo-wei', + maria: 'demo-maria', + lisa: 'demo-lisa', + support: SUPPORT_ACCOUNT_ID, +} as const; + +const TZ = 'Europe/Berlin'; + +const individual = (id: string, person: Person): Principal => ({ + id, type: 'individual', name: person.name, description: person.name, email: person.email, timeZone: TZ, accountId: id, +}); + +export function createDemoPrincipals(): Principal[] { + return [ + individual(PRINCIPAL.nora, NORA), + individual(PRINCIPAL.alice, ALICE), + individual(PRINCIPAL.bob, BOB), + individual(PRINCIPAL.priya, PRIYA), + individual(PRINCIPAL.ahmed, AHMED), + individual(PRINCIPAL.james, JAMES), + individual(PRINCIPAL.michael, MICHAEL), + individual(PRINCIPAL.rachel, RACHEL), + individual(PRINCIPAL.wei, WEI), + individual(PRINCIPAL.maria, MARIA), + individual(PRINCIPAL.lisa, LISA), + { + id: PRINCIPAL.support, type: 'group', name: SUPPORT.name, description: 'Customer support, answered by the Platform team', + email: SUPPORT.email, timeZone: TZ, accountId: PRINCIPAL.support, + }, + ]; +} + +/** Grants on Nora's own folders, by folder id: Alice reads the Inbox while Nora is in Lisbon. */ +export function createDemoMailboxShares(): Record> { + return { + [MB.inbox]: { [PRINCIPAL.alice]: { ...MAILBOX_PRESETS.read } }, + }; +}