diff --git a/lib/demo/__tests__/fixtures.test.ts b/lib/demo/__tests__/fixtures.test.ts index fd40201a9..4fe9a67d2 100644 --- a/lib/demo/__tests__/fixtures.test.ts +++ b/lib/demo/__tests__/fixtures.test.ts @@ -33,6 +33,8 @@ import { availabilityFor } from '@/components/calendar/participant-availability' import { demoAt } from '../demo-utils'; import type { BusyPeriod } from '@/lib/jmap/types'; import { useRecoveryStore } from '@/stores/recovery-store'; +import { SUPPORT_TAGS } from '../fixtures/shared-metadata'; +import { loadSharedTags, sharedTagsKey, useSharedTagsStore, saveSharedTags } from '@/stores/shared-tags-store'; const PUBLIC = path.resolve(__dirname, '..', '..', '..', 'public'); const work = getDemoData('work'); @@ -95,16 +97,20 @@ describe('demo fixtures: structure', () => { }); it('every tag on a message has a definition with a palette colour', () => { - const defined = new Set(DEMO_KEYWORDS.map((k) => `$label:${k.id}`)); - for (const k of DEMO_KEYWORDS) expect(KEYWORD_PALETTE[k.color], `colour ${k.color}`).toBeDefined(); - for (const e of [...work.emails, ...personal.emails]) { - for (const keyword of Object.keys(e.keywords)) { - if (keyword.startsWith('$label:')) expect(defined.has(keyword), `${e.id} carries undefined tag ${keyword}`).toBe(true); + // Nora's own tags on her mail; the Support team's shared tags on Support mail. + const isSupport = (e: (typeof work.emails)[number]) => Object.keys(e.mailboxIds).some((id) => id.startsWith('demo-support-')); + const own = [...work.emails.filter((e) => !isSupport(e)), ...personal.emails]; + const team = work.emails.filter(isSupport); + for (const [definitions, emails] of [[DEMO_KEYWORDS, own], [SUPPORT_TAGS, team]] as const) { + const defined = new Set(definitions.map((k) => `$label:${k.id}`)); + for (const k of definitions) expect(KEYWORD_PALETTE[k.color], `colour ${k.color}`).toBeDefined(); + for (const e of emails) { + for (const keyword of Object.keys(e.keywords)) { + if (keyword.startsWith('$label:')) expect(defined.has(keyword), `${e.id} carries undefined tag ${keyword}`).toBe(true); + } } - } - // The sidebar should have something to count for each tag. - for (const tag of defined) { - expect(work.emails.some((e) => e.keywords[tag]) || personal.emails.some((e) => e.keywords[tag]), `${tag} is never used`).toBe(true); + // The sidebar should have something to count for each tag. + for (const tag of defined) expect(emails.some((e) => e.keywords[tag]), `${tag} is never used`).toBe(true); } }); @@ -467,4 +473,24 @@ describe('demo fixtures: audience screenshots', () => { expect((await client.restoreArchivedItems!([items[0].id]))[0]).toMatchObject({ ok: false, type: 'notFound' }); expect(new DemoJMAPClient('personal').advertisesArchivedItems!()).toBe(false); }); + + it('the Support mailbox shows the team\'s shared tags, and a member\'s change is kept', async () => { + const client = new DemoJMAPClient('work'); + const key = sharedTagsKey('fixture-test', SUPPORT_ACCOUNT_ID); + await loadSharedTags(key, SUPPORT_ACCOUNT_ID, client); + const entry = useSharedTagsStore.getState().entries[key]; + expect(entry.status).toBe('ready'); + expect(entry.tags.map((t) => `${t.label}:${t.color}`)).toEqual(['Accounts:orange', 'Billing:green', 'Bug:red', 'Integration:purple']); + expect(entry.discovered).toMatchObject({ '$label:billing': 2, '$label:integration': 3 }); + + await saveSharedTags(key, SUPPORT_ACCOUNT_ID, client, [{ id: 'bug', value: { label: 'Bug report', color: 'red' } }]); + const stored = await client.getBulwarkMetadata!(SUPPORT_ACCOUNT_ID); + expect((stored!.tags as { tags: Record }).tags.bug.label).toBe('Bug report'); + expect((stored!.tags as { tags: Record }).tags.billing).toBeDefined(); + + // Nora's own account keeps none (her templates stay on the device), and its tags don't mix with the team's. + expect(client.supportsBulwarkMetadata!(client.getAccountId())).toBe(false); + const own = await client.discoverKeywords(); + expect(Object.keys(own.keywords).some((k) => SUPPORT_TAGS.some((t) => k === `$label:${t.id}`))).toBe(false); + }); }); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index d76e11f3c..07841951a 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -8,6 +8,10 @@ import { createDemoBusyBlocks } from './fixtures/availability'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; import { createDemoArchivedItems, type DemoArchivedItem } from './fixtures/archive'; +import { createDemoSharedMetadata } from './fixtures/shared-metadata'; +import { + MAX_METADATA_ENTRY_BYTES, MetadataPatchError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, +} from '@/lib/jmap/bulwark-metadata'; import { hasArchivedEmail as hasArchivedEmailIn, listArchivedEmailsPaged, type ArchiveIo, type ArchiveListResult, type ArchiveQueryPage, type RestoreResult, @@ -58,6 +62,10 @@ export class DemoJMAPClient implements IJMAPClient { private readonly principals: Principal[]; /** Mail deleted for good that the server still keeps (Recover deleted items). */ private archive: DemoArchivedItem[]; + /** Bulwark's shared Inbox metadata by account and key; only accounts listed here have any. */ + private metadata: Record>; + /** The same for each account's private metadata. */ + private privateMetadata: Record> = {}; private blobStore = new Map(); private scheduledSubmissions = new Map(); private connectionCallback: ((connected: boolean) => void) | null = null; @@ -70,6 +78,7 @@ export class DemoJMAPClient implements IJMAPClient { this.data = getDemoData(profile); this.principals = profile === 'personal' ? [] : createDemoPrincipals(); this.archive = profile === 'personal' ? [] : createDemoArchivedItems(this.data.accountId); + this.metadata = profile === 'personal' ? {} : createDemoSharedMetadata(); // 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) { @@ -322,6 +331,67 @@ export class DemoJMAPClient implements IJMAPClient { .sort((a, b) => a.utcStart.localeCompare(b.utcStart)); } + // ── Bulwark data in Inbox metadata (urn:ietf:params:jmap:metadata) ── + // Only the Support group keeps any (fixtures/shared-metadata.ts): its + // shared tags and the team inbox plugin's names. Keys are without the + // `org.bulwarkmail.` namespace, as JMAPClient hands them out. + + supportsBulwarkMetadata(accountId?: string): boolean { + return !!accountId && Object.prototype.hasOwnProperty.call(this.metadata, accountId); + } + + advertisesBulwarkMetadata(accountId?: string): boolean { + return this.supportsBulwarkMetadata(accountId); + } + + async getBulwarkMetadata(accountId?: string, options?: { private?: boolean }): Promise | null> { + if (!this.supportsBulwarkMetadata(accountId)) return null; + const store = options?.private ? this.privateMetadata : this.metadata; + return structuredClone(store[accountId!] ?? {}); + } + + async setBulwarkMetadata(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise { + if (!this.supportsBulwarkMetadata(accountId)) throw new MetadataUnsupportedError(); + if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) { + throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`); + } + const store = options?.private ? this.privateMetadata : this.metadata; + const entries = (store[accountId!] ??= {}); + if (value === null) delete entries[key]; + else entries[key] = structuredClone(value); + this.notifyMailboxChange(accountId!); + } + + /** Sets (null: removes) values inside one entry; a path whose parent is missing fails as on Stalwart. */ + async patchBulwarkMetadata(accountId: string | undefined, key: string, patches: Array<{ path: string[]; value: unknown }>): Promise<{ newState?: string }> { + if (!this.supportsBulwarkMetadata(accountId)) throw new MetadataUnsupportedError(); + const entries = this.metadata[accountId!]; + const isObject = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); + let result: unknown = structuredClone(entries[key]); + for (const { path, value } of patches) { + if (path.length === 0) { result = structuredClone(value); continue; } + let node: unknown = result; + for (const segment of path.slice(0, -1)) node = isObject(node) ? node[segment] : undefined; + if (!isObject(node)) throw new MetadataPatchError('A parent of the patched value does not exist'); + const last = path[path.length - 1]; + if (value === null) delete node[last]; + else node[last] = structuredClone(value); + } + if (result !== null && result !== undefined && metadataValueBytes(result) > MAX_METADATA_ENTRY_BYTES) { + throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`); + } + if (result === null || result === undefined) delete entries[key]; + else entries[key] = result; + return { newState: this.notifyMailboxChange(accountId!) }; + } + + /** A metadata change bumps the account's Mailbox state, as Stalwart does; returns the new state. */ + private notifyMailboxChange(accountId: string): string { + const state = generateDemoId('state'); + this.stateChangeCallback?.({ '@type': 'StateChange', changed: { [accountId]: { Mailbox: state } } }); + return state; + } + /** 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; @@ -583,11 +653,14 @@ export class DemoJMAPClient implements IJMAPClient { limit?: number; onProgress?: (scanned: number, total: number) => void; signal?: AbortSignal; + accountId?: string; }): Promise<{ keywords: Record; scanned: number; total: number; complete: boolean }> { - const total = this.data.emails.length; + // One account's mail: the Support group's tags are not Nora's own. + const emails = this.data.emails.filter(this.inAccount(options?.accountId)); + const total = emails.length; const scanned = Math.min(total, Math.max(0, options?.limit ?? total)); const keywords: Record = {}; - for (const email of this.data.emails.slice(0, scanned)) { + for (const email of emails.slice(0, scanned)) { for (const [keyword, isSet] of Object.entries(email.keywords || {})) { if (isSet) keywords[keyword] = (keywords[keyword] ?? 0) + 1; } diff --git a/lib/demo/fixtures/shared-metadata.ts b/lib/demo/fixtures/shared-metadata.ts new file mode 100644 index 000000000..63f28ea24 --- /dev/null +++ b/lib/demo/fixtures/shared-metadata.ts @@ -0,0 +1,38 @@ +/** + * What the Support group keeps in its Inbox's shared metadata (JMAP + * metadata, Stalwart 1.0; lib/jmap/bulwark-metadata.ts), by key without the + * `org.bulwarkmail.` namespace: + * - `tags`: the team's tag names and colours (lib/shared-tags.ts), which + * every member sees on the Support mail; + * - `plugin.team-inbox`: the names behind the principal ids in the team + * inbox plugin's `$assigned:` keywords (its src/lib/names.js shape). + * Only the Support account has metadata in the demo; Nora's own account + * keeps none, so her templates stay on the device. + */ +import type { KeywordDefinition } from '@/stores/settings-store'; +import { SUPPORT_ACCOUNT_ID } from './mailboxes'; +import { PRINCIPAL } from './directory'; +import { BOB, NORA } from './people'; + +/** The Support team's tags, as members see them. */ +export const SUPPORT_TAGS: KeywordDefinition[] = [ + { id: 'billing', label: 'Billing', color: 'green' }, + { id: 'integration', label: 'Integration', color: 'purple' }, + { id: 'accounts', label: 'Accounts', color: 'orange' }, + { id: 'bug', label: 'Bug', color: 'red' }, +]; + +export function createDemoSharedMetadata(): Record> { + return { + [SUPPORT_ACCOUNT_ID]: { + tags: { tags: Object.fromEntries(SUPPORT_TAGS.map(({ id, label, color }) => [id, { label, color }])) }, + 'plugin.team-inbox': { + v: 1, + people: { + [PRINCIPAL.nora]: { n: NORA.name, e: NORA.email }, + [PRINCIPAL.bob]: { n: BOB.name, e: BOB.email }, + }, + }, + }, + }; +} diff --git a/lib/demo/fixtures/support.ts b/lib/demo/fixtures/support.ts index dc6554c40..881284754 100644 --- a/lib/demo/fixtures/support.ts +++ b/lib/demo/fixtures/support.ts @@ -16,6 +16,7 @@ export function createDemoSupportEmails(): Email[] { id: 'demo-support-tomas', mailbox: SMB.inbox, date: ago(0, 55), from: addr(TOMAS), to: [support], subject: 'Beacon add-on: can we go to 100 tenants before launch?', + keywords: ['$label:billing'], text: 'Hello,\n\nOur PO covers fifty tenants on the Beacon add-on. Operations now wants all hundred Lisbon tenants on it from day one. Can the add-on be raised before the launch, and does that change the canary plan Nora mentioned?\n\nThank you,\nTomás Ferreira\nLumen Logistics', headers: authPass('lumenlogistics.example'), }), @@ -23,6 +24,7 @@ export function createDemoSupportEmails(): Email[] { id: 'demo-support-lena-address', mailbox: SMB.inbox, date: at(1, 11, 18), from: addr(LENA), to: [support], subject: 'Invoice address change', + keywords: ['$label:billing'], text: 'Dear Meridian team,\n\nPlease send future invoices to our new address:\n\nHoffmann & Partner Steuerberatung\nFriedrichstraße 200\n10117 Berlin\n\nThe VAT id stays the same. Could you confirm once it is changed?\n\nKind regards,\nLena Hoffmann', headers: authPass('hoffmann-partner.example'), }), @@ -31,7 +33,7 @@ export function createDemoSupportEmails(): Email[] { from: addr(YUSUF), to: [support], subject: 'Re: Webhook signature verification in PHP?', text: 'That worked, thank you! The timestamp tolerance was the missing piece. You can close this.\n\nYusuf', - keywords: ['$seen'], + keywords: ['$seen', '$label:integration'], headers: authPass('demir-retail.example'), inReplyTo: [mid('demo-support-yusuf-reply')], references: [mid('demo-support-yusuf-1', 'demir-retail.example'), mid('demo-support-yusuf-reply')], }), @@ -40,7 +42,7 @@ export function createDemoSupportEmails(): Email[] { from: addr(YUSUF), to: [support], subject: 'Webhook signature verification in PHP?', text: 'Hi,\n\nWe are testing the Beacon beta. The docs show signature verification in Node and Python; is there a PHP example? Our shop runs on Laravel. Verification fails for us with "timestamp outside tolerance", although the clocks look fine.\n\nYusuf Demir\nDemir Retail', - keywords: ['$seen'], + keywords: ['$seen', '$label:integration'], headers: authPass('demir-retail.example'), }), mail({ @@ -48,7 +50,7 @@ export function createDemoSupportEmails(): Email[] { from: addr(LENA), to: [support], subject: 'Access for a departed employee', text: `Hello,\n\nA colleague left us at the end of last month and still has an active login to our Meridian workspace. Could you disable it? The address is j.becker@hoffmann-partner.example. I am the account owner.\n\nThank you,\nLena Hoffmann`, - keywords: ['$seen'], + keywords: ['$seen', '$label:accounts'], headers: authPass('hoffmann-partner.example'), }), @@ -58,7 +60,7 @@ export function createDemoSupportEmails(): Email[] { from: support, to: [addr(YUSUF)], subject: 'Re: Webhook signature verification in PHP?', text: `Hi Yusuf,\n\nThere is no PHP example in the docs yet; one is coming with the launch. Until then, the two things that usually go wrong:\n\n1. Sign the raw request body, not the decoded JSON.\n2. The timestamp in X-Beacon-Timestamp is in seconds, and the tolerance is five minutes. "Outside tolerance" nearly always means the body was re-encoded or the header was read as milliseconds.\n\nA minimal example:\n\n $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);\n hash_equals($expected, $signature);\n\nTell me if that does not fix it and I will look at a request with you.\n\nNora, for Meridian Support`, - keywords: ['$seen'], + keywords: ['$seen', '$label:integration'], inReplyTo: [mid('demo-support-yusuf-1', 'demir-retail.example')], references: [mid('demo-support-yusuf-1', 'demir-retail.example')], }), @@ -67,7 +69,7 @@ export function createDemoSupportEmails(): Email[] { from: { name: 'Petra Novak', email: 'petra@novak-consulting.example' }, to: [support], subject: 'Re: SSO login loop after the provider change', text: `Confirmed fixed on our side, thank you for the quick turnaround ${dayName(-9)}.\n\nPetra`, - keywords: ['$seen'], headers: authPass('novak-consulting.example'), + keywords: ['$seen', '$label:bug'], headers: authPass('novak-consulting.example'), }), ]; }