feat: give the demo client shared metadata for tags and assignments

This commit is contained in:
Linus Rath
2026-10-31 13:58:57 +01:00
parent 7c6addb2a5
commit 5add641297
4 changed files with 155 additions and 16 deletions
+35 -9
View File
@@ -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<string, { label: string }> }).tags.bug.label).toBe('Bug report');
expect((stored!.tags as { tags: Record<string, unknown> }).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);
});
});
+75 -2
View File
@@ -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<string, Record<string, unknown>>;
/** The same for each account's private metadata. */
private privateMetadata: Record<string, Record<string, unknown>> = {};
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;
@@ -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<Record<string, unknown> | 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<void> {
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<string, unknown> => !!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<string, AnyRights> | 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<string, number>; 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<string, number> = {};
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;
}
+38
View File
@@ -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:<id>` 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<string, Record<string, unknown>> {
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 },
},
},
},
};
}
+7 -5
View File
@@ -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'),
}),
];
}