mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
feat: give the demo client principals and sharing
This commit is contained in:
@@ -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
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 } },
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user