mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
feat: give the demo client shared metadata for tags and assignments
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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'),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user