feat: give the demo client principals and sharing

This commit is contained in:
Linus Rath
2026-10-31 13:32:02 +01:00
parent cd9a529b20
commit dae45e1203
4 changed files with 239 additions and 8 deletions
+44 -1
View File
@@ -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();
});
});
+120 -6
View File
@@ -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<string, Blob>();
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
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<never[]> { return []; }
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
async setAddressBookShare(): Promise<void> { /* 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<Principal[]> {
return this.principals.map((p) => ({ ...p }));
}
async listPrincipals(): Promise<PrincipalListing> {
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<Record<string, string> | 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<Record<string, MailboxRights> | null> {
const mailbox = this.ownShareItem('mailbox', mailboxId, accountId);
if (!mailbox) throw new Error('Folder not found');
return mailbox.shareWith ? structuredClone(mailbox.shareWith) as Record<string, MailboxRights> : null;
}
async setMailboxShare(mailboxId: string, principalId: string, rights: MailboxRights | null, accountId?: string): Promise<void> {
this.setShare('mailbox', mailboxId, principalId, rights, accountId);
}
async setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise<void> {
this.setShare('calendar', calendarId, principalId, rights, targetAccountId);
}
async setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void> {
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<string, AnyRights> | 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<ShareChangeResult[]> {
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<string, AnyRights> | null } | null {
if (!this.supportsPrincipals() || (accountId && accountId !== this.data.accountId)) return null;
const own = <T extends { id: string; originalId?: string; isShared?: boolean }>(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<void> { /* demo: no-op */ }
async setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void> {
this.setShare('file', fileNodeId, principalId, rights, targetAccountId);
}
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
if (ids === null) return [...this.data.fileNodes];
+7 -1
View File
@@ -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<string, Mailbox['shareWith']>): 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(),
+68
View File
@@ -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<string, Record<string, MailboxRights>> {
return {
[MB.inbox]: { [PRINCIPAL.alice]: { ...MAILBOX_PRESETS.read } },
};
}