feat: rework the demo around one persona with two accounts and a shared mailbox

The demo user becomes Nora Lindqvist, engineering lead at Meridian Labs,
in the week of a product launch. The fixtures tell one story across mail,
contacts, calendar, tasks and files, and exercise the features the
screenshots and the public demo should show:

- a second, mail-only login (her personal domain) and the shared Support
  mailbox delegated to the work account, so the account switcher, the
  shared section and the merged inbox have real content
- raw headers the way Stalwart writes them: the sender check fails on
  the phishing mail, newsletters carry a one-click unsubscribe, one mail
  asks for a read receipt, and spam scores show in the details
- an iMIP invitation with a served .ics, an event waiting for the answer
  on the calendar, and RSVP patches applied in the demo client
- @mentions as mailto links in a received mail, a verification code
  twelve minutes old, nested folders, named tags on the mail, contact
  groups, a scheduled message and tasks switched on
- every attachment and file backed by real bytes under public/demo,
  generated by `scripts/demo-assets/generate.mjs`; contact photos are
  bundled portraits instead of requests to randomuser.me
- folder counts computed from the mail instead of hand-kept numbers

Demo login seeds the tags, templates and merged-inbox settings once per
device and switches the cross-account gate on for the session; "Reset"
restores them. `bulwark-demo-simulate=off` in localStorage stops the
simulated incoming mail for screenshot runs.
This commit is contained in:
Linus Rath
2026-10-18 19:48:11 +02:00
parent eb18a13878
commit 4c0017d0c1
69 changed files with 3036 additions and 1711 deletions
+2
View File
@@ -832,6 +832,8 @@ function DemoBanner() {
const handleReset = async () => {
setIsResetting(true);
router.push('/');
// Also put the seeded demo preferences (tags, merged inbox, templates) back.
try { localStorage.removeItem('bulwark-demo-seed'); } catch { /* storage blocked */ }
await loginDemo();
setIsResetting(false);
};
+1 -1
View File
@@ -62,7 +62,7 @@ export function AccountSettings() {
// `account.displayName` is refreshed from the server on every login/restore
// (Stalwart principal "Full name" when available - #900); the identity name
// is only a fallback until that entry exists.
const displayName = account?.displayName || primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
const displayName = account?.displayName || primaryIdentity?.name || (isDemoMode ? 'Nora Lindqvist' : undefined);
const email = primaryIdentity?.email || account?.email || username;
const max = getMaxAccounts();
+292
View File
@@ -0,0 +1,292 @@
/**
* The demo data is what every screenshot and the public demo show. These
* tests pin the facts the drivers and the viewer rely on: ids resolve, the
* files behind attachments exist with the recorded sizes, and the fixtures
* trigger the features they are written for (mention chips, the invitation
* banner, the verification-code chip, the sender-authentication warning,
* unsubscribe, read receipts).
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { getDemoData } from '../demo-data';
import { DemoJMAPClient } from '../demo-client';
import { DEMO_ASSETS } from '../fixtures/assets';
import { PORTRAITS, NORA, ALICE, BOB } 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';
import { DEMO_KEYWORDS } from '../fixtures/keywords';
import { getSenderVerification, extractListHeaders } from '@/lib/email-headers';
import { findVerificationCode } from '@/lib/verification-code';
import { findCalendarAttachment } from '@/lib/calendar-invitation';
import { mentionFromLink } from '@/lib/recipient-mentions';
import { KEYWORD_PALETTE } from '@/stores/settings-store';
const PUBLIC = path.resolve(__dirname, '..', '..', '..', 'public');
const work = getDemoData('work');
const personal = getDemoData('personal');
function allMailboxIds(data: ReturnType<typeof getDemoData>): Set<string> {
const ids = data.mailboxes.map((m) => m.id);
for (const shared of Object.values(data.sharedMailboxes)) ids.push(...shared.mailboxes.map((m) => m.id));
return new Set(ids);
}
describe('demo fixtures: structure', () => {
it.each([['work', work], ['personal', personal]] as const)('%s mail only uses folders that exist and has unique ids', (_name, data) => {
const mailboxIds = allMailboxIds(data);
const seenIds = new Set<string>();
const seenMessageIds = new Set<string>();
for (const email of data.emails) {
expect(seenIds.has(email.id), `duplicate id ${email.id}`).toBe(false);
seenIds.add(email.id);
expect(seenMessageIds.has(email.messageId!), `duplicate Message-ID ${email.messageId}`).toBe(false);
seenMessageIds.add(email.messageId!);
for (const mailboxId of Object.keys(email.mailboxIds)) {
expect(mailboxIds.has(mailboxId), `${email.id} is filed in unknown folder ${mailboxId}`).toBe(true);
}
}
});
it('reply chains point at messages that exist', () => {
const known = new Set([...work.emails, ...personal.emails].map((e) => e.messageId));
for (const email of [...work.emails, ...personal.emails]) {
for (const ref of [...(email.inReplyTo ?? []), ...(email.references ?? [])]) {
expect(known.has(ref), `${email.id} references ${ref}`).toBe(true);
}
}
});
it('every thread with several messages shares one subject stem', () => {
const byThread = new Map<string, typeof work.emails>();
for (const e of [...work.emails, ...personal.emails]) byThread.set(e.threadId, [...(byThread.get(e.threadId) ?? []), e]);
for (const [threadId, emails] of byThread) {
if (emails.length < 2) continue;
const stems = new Set(emails.map((e) => (e.subject ?? '').replace(/^(re|fwd?):\s*/i, '').toLowerCase()));
expect(stems.size, `thread ${threadId} mixes subjects ${[...stems].join(' | ')}`).toBe(1);
}
});
it('nothing in an inbox is dated in the future', () => {
const now = Date.now();
for (const e of [...work.emails, ...personal.emails]) {
expect(new Date(e.receivedAt).getTime(), `${e.id} is in the future`).toBeLessThanOrEqual(now);
}
});
it('the inbox is busy enough to scroll and about a third unread', () => {
const inbox = work.emails.filter((e) => e.mailboxIds[MB.inbox]);
expect(inbox.length).toBeGreaterThanOrEqual(35);
const unread = inbox.filter((e) => !e.keywords.$seen).length / inbox.length;
expect(unread).toBeGreaterThan(0.25);
expect(unread).toBeLessThan(0.5);
});
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);
}
}
// 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);
}
});
it('calendar items and files point at calendars and assets that exist', () => {
const calendars = new Set(work.calendars.map((c) => c.id));
for (const ev of work.calendarEvents) for (const id of Object.keys(ev.calendarIds)) expect(calendars.has(id), ev.id).toBe(true);
for (const t of work.calendarTasks) for (const id of Object.keys(t.calendarIds)) expect(calendars.has(id), t.id).toBe(true);
for (const node of work.fileNodes) {
if (node.blobId) expect(DEMO_ASSETS[node.blobId as keyof typeof DEMO_ASSETS], `${node.name} -> ${node.blobId}`).toBeDefined();
if (node.parentId) expect(work.fileNodes.some((n) => n.id === node.parentId), `${node.name} parent`).toBe(true);
}
});
it('contact groups only list contacts that exist', () => {
const ids = new Set(work.contacts.map((c) => c.id));
for (const group of work.contacts.filter((c) => c.kind === 'group')) {
for (const member of Object.keys(group.members ?? {})) expect(ids.has(member), `${group.id} -> ${member}`).toBe(true);
}
expect(work.contacts.filter((c) => c.kind === 'group').length).toBeGreaterThanOrEqual(3);
});
it('people who write to Nora from the cast are in her address books', () => {
const contactEmails = new Set(work.contacts.flatMap((c) => Object.values(c.emails ?? {}).map((e) => e.address.toLowerCase())));
for (const p of PORTRAITS) {
if (p.email === NORA.email) continue;
expect(contactEmails.has(p.email.toLowerCase()), `${p.name} has a portrait but no contact card`).toBe(true);
}
});
});
describe('demo fixtures: files behind the data', () => {
it('every asset exists in public/demo with the recorded size', () => {
for (const [blobId, asset] of Object.entries(DEMO_ASSETS)) {
const file = path.join(PUBLIC, asset.path);
expect(fs.existsSync(file), `${blobId}: ${asset.path} missing`).toBe(true);
expect(fs.statSync(file).size, `${asset.path} size changed; update fixtures/assets.ts`).toBe(asset.size);
}
});
it('every attachment is backed by an asset', () => {
for (const e of [...work.emails, ...personal.emails]) {
for (const att of e.attachments ?? []) {
if (att.blobId === GONOGO_INVITE_BLOB) continue;
expect(DEMO_ASSETS[att.blobId as keyof typeof DEMO_ASSETS], `${e.id} -> ${att.name}`).toBeDefined();
}
}
});
it('every portrait exists and is small', () => {
for (const p of PORTRAITS) {
const file = path.join(PUBLIC, 'demo', 'portraits', `${p.portrait}.jpg`);
expect(fs.existsSync(file), `${p.name}: ${file}`).toBe(true);
expect(fs.statSync(file).size).toBeLessThan(30_000);
}
});
it('contact photos are served locally, not from a third party', () => {
for (const c of work.contacts) {
for (const m of Object.values(c.media ?? {})) expect(m.uri).toMatch(/^\/demo\/portraits\//);
}
});
});
describe('demo fixtures: features the screenshots show', () => {
const client = new DemoJMAPClient('work');
it('derives sender authentication from the headers: legit mail passes, the phishing mail fails', async () => {
const legit = (await client.getEmail('demo-email-sarah-invoice'))!;
expect(legit.authenticationResults?.dmarc?.result).toBe('pass');
expect(getSenderVerification(legit.authenticationResults, legit.from![0].email)).toBeNull();
const phish = (await client.getEmail('demo-email-spam-phish'))!;
expect(getSenderVerification(phish.authenticationResults, phish.from![0].email)?.status).toBe('failed');
expect(phish.spamScore).toBeGreaterThan(5);
expect(phish.spamLLM?.verdict).toBe('SPAM');
});
it('newsletters carry a one-click unsubscribe the viewer can act on', async () => {
const nl = (await client.getEmail('demo-email-techdigest'))!;
const list = extractListHeaders(nl.headers!);
expect(list.listUnsubscribe?.http).toMatch(/^https:\/\//);
expect(list.listUnsubscribe?.mailto).toContain('@');
expect(list.listUnsubscribe?.preferred).toBe('http');
});
it('the launch plan asks for a read receipt', async () => {
const e = (await client.getEmail('demo-email-launch-plan'))!;
expect(e.headers?.['Disposition-Notification-To']).toContain('michael.torres@');
});
it('the GitHub mail shows a verification-code chip', async () => {
const e = (await client.getEmail('demo-email-github-code'))!;
expect(findVerificationCode(e.subject, e.preview)).toBe('482913');
expect(Date.now() - new Date(e.receivedAt).getTime()).toBeLessThan(60 * 60 * 1000);
});
it('Alice mentions Nora and Bob with links the viewer turns into chips', async () => {
const e = (await client.getEmail('demo-email-rollback-1'))!;
const html = e.bodyValues![e.htmlBody![0].partId].value;
const doc = new DOMParser().parseFromString(html, 'text/html');
const mentions = [...doc.querySelectorAll('a')].map((a) => mentionFromLink(a as HTMLElement)).filter(Boolean);
expect(mentions).toEqual([
{ label: 'Nora', email: NORA.email },
{ label: 'Bob', email: BOB.email },
]);
});
it('the go/no-go mail is an invitation Nora can answer', async () => {
const e = (await client.getEmail('demo-email-gonogo-invite'))!;
const att = findCalendarAttachment(e);
expect(att?.blobId).toBe(GONOGO_INVITE_BLOB);
const ics = createGoNoGoInvitationIcs();
expect(ics).toContain('METHOD:REQUEST');
expect(ics).toContain(`UID:${GONOGO_UID}`);
expect(await (await client.fetchBlob(GONOGO_INVITE_BLOB)).text()).toBe(ics);
const [parsed] = await client.parseCalendarEvents('demo-account', GONOGO_INVITE_BLOB);
expect(parsed.uid).toBe(GONOGO_UID);
const me = Object.values(parsed.participants!).find((p) => p.email === NORA.email)!;
expect(me.participationStatus).toBe('needs-action');
expect(Object.values(parsed.participants!).some((p) => p.roles.owner && p.email === ALICE.email)).toBe(true);
// The event is already on the calendar, waiting for the answer.
expect((await client.queryCalendarEvents({ uid: GONOGO_UID }))[0]?.id).toBe(GONOGO_EVENT_ID);
});
it('an RSVP arrives as a JSON-pointer patch and lands on the participant', async () => {
const fresh = new DemoJMAPClient('work');
const event = (await fresh.getCalendarEvent(GONOGO_EVENT_ID))!;
const [key] = Object.entries(event.participants!).find(([, p]) => p.email === NORA.email)!;
await fresh.updateCalendarEvent(GONOGO_EVENT_ID, { [`participants/${key}/participationStatus`]: 'accepted' } as never);
expect((await fresh.getCalendarEvent(GONOGO_EVENT_ID))!.participants![key].participationStatus).toBe('accepted');
expect(Object.keys((await fresh.getCalendarEvent(GONOGO_EVENT_ID))!)).not.toContain(`participants/${key}/participationStatus`);
});
it('the Support mailbox is listed as a shared account next to the work folders', async () => {
const all = await client.getAllMailboxes();
const shared = all.filter((m) => m.isShared);
expect(shared.map((m) => m.id)).toContain(`${SUPPORT_ACCOUNT_ID}:${SMB.inbox}`);
expect(shared.every((m) => m.accountId === SUPPORT_ACCOUNT_ID && m.accountName === 'Support')).toBe(true);
const inbox = shared.find((m) => m.originalId === SMB.inbox)!;
expect(inbox.unreadEmails).toBeGreaterThan(0);
// Queried the way the store does it: by the bare id and the owner account.
const { emails } = await client.getEmails(SMB.inbox, SUPPORT_ACCOUNT_ID);
expect(emails.length).toBe(inbox.totalEmails);
expect(client.getSharedAccounts().map((a) => a.id)).toEqual(['demo-account', SUPPORT_ACCOUNT_ID]);
});
it('folder counts come from the mail, including nested folders', async () => {
const mailboxes = await client.getMailboxes();
for (const mb of mailboxes) {
const inFolder = work.emails.filter((e) => e.mailboxIds[mb.id]);
expect(mb.totalEmails, mb.name).toBe(inFolder.length);
expect(mb.unreadEmails, mb.name).toBe(inFolder.filter((e) => !e.keywords.$seen).length);
}
expect(mailboxes.find((m) => m.id === MB.beacon)?.parentId).toBe(MB.projects);
expect(mailboxes.find((m) => m.id === MB.archive2025)?.parentId).toBe(MB.archive);
});
it('the personal account is a separate mail-only login', async () => {
const second = new DemoJMAPClient('personal');
expect(second.getAccountId()).not.toBe(client.getAccountId());
expect(second.getUsername()).not.toBe(client.getUsername());
expect(second.supportsCalendars()).toBe(false);
expect(second.supportsContacts()).toBe(false);
expect((await second.getMailboxes()).find((m) => m.role === 'inbox')?.unreadEmails).toBeGreaterThan(0);
});
it('"recent mail" on a contact card finds mail to and from that person', async () => {
const { emails } = await client.advancedSearchEmails({ operator: 'OR', conditions: [{ from: ALICE.email }, { to: ALICE.email }] });
expect(emails.length).toBeGreaterThan(2);
expect(emails.every((e) => [...(e.from ?? []), ...(e.to ?? [])].some((a) => a.email === ALICE.email))).toBe(true);
});
it('a scheduled message is waiting in Drafts', async () => {
const { emails } = await client.getScheduledEmails();
expect(emails.map((e) => e.id)).toContain('demo-email-scheduled-prep');
expect(new Date(emails[0].scheduledSendAt).getTime()).toBeGreaterThan(Date.now());
});
});
describe('demo fixtures: calendar times', () => {
it('utcStart matches the local wall-clock start of every timed event', () => {
for (const ev of work.calendarEvents) {
if (ev.showWithoutTime || !ev.utcStart) continue;
const local = new Date(ev.start);
expect(new Date(ev.utcStart).getTime(), `${ev.title}: ${ev.start} vs ${ev.utcStart}`).toBe(local.getTime());
}
});
it('a quarter-hour standup lasts fifteen minutes', () => {
const standup = work.calendarEvents.find((e) => e.id === 'demo-event-standup')!;
expect(standup.duration).toBe('PT15M');
expect(new Date(standup.utcEnd!).getTime() - new Date(standup.utcStart!).getTime()).toBe(15 * 60 * 1000);
});
});
+178 -70
View File
@@ -1,34 +1,40 @@
import type { IJMAPClient, KeywordDiscoveryResult, KeywordMigration } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { getDemoData, demoSharedAccounts, type DemoData, type DemoProfile } from './demo-data';
import { generateDemoId } from './demo-utils';
import { DEMO_ASSETS } from './fixtures/assets';
import { GONOGO_INVITE_BLOB } from './fixtures/emails';
import { createGoNoGoInvitationIcs, GONOGO_EVENT_ID } from './fixtures/calendars';
import { ALICE, BOB, SARAH, PRIYA, LISA } from './fixtures/people';
import { parseAuthenticationResults, parseSpamLLM, parseSpamScore } from '@/lib/email-headers';
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
import { withBasePath } from '@/lib/browser-navigation';
import { toInertBlob } from '@/lib/file-preview';
import type { StorageQuota } from "@/lib/quota";
// Fixture blobs with real bytes behind them, served from public/demo/. The
// photos are CC0 / public domain from Wikimedia Commons: "Wedding couple
// (1294004)", "Wedding couple (1293575)", "Men drinking", "Lake Mountain
// Landscape" and "Team members of startup". prototype-v3.png is drawn for the
// demo. Every other fixture blob falls back to a text placeholder.
const DEMO_BLOB_ASSETS: Record<string, string> = {
'demo-blob-att-2': '/demo/prototype-v3.png',
'demo-blob-att-3': '/demo/wedding-001.jpg',
'demo-blob-att-4': '/demo/wedding-014-mom-dad.jpg',
'demo-blob-att-5': '/demo/wedding-038-the-toast.jpg',
'demo-blob-file-4': '/demo/vacation.jpg',
'demo-blob-file-5': '/demo/team-photo.jpg',
};
// Fixture blobs with real bytes behind them, served from public/demo/ (see
// fixtures/assets.ts for the list and the licences). Any other blob id falls
// back to a text placeholder.
const DEMO_BLOB_ASSETS: Record<string, string> = Object.fromEntries(
Object.entries(DEMO_ASSETS).map(([blobId, asset]) => [blobId, asset.path]),
);
/** localStorage key that switches the simulated incoming mail off (screenshot runs). */
export const DEMO_SIMULATE_KEY = 'bulwark-demo-simulate';
/**
* In-memory JMAP client for demo mode.
* All data lives in memory - no server calls, no cookies. Only the demo
* images under public/demo are fetched, from the app's own origin.
*
* One instance per demo login: `new DemoJMAPClient('work')` is the primary
* account (with the shared Support mailbox delegated to it), and
* `new DemoJMAPClient('personal')` the mail-only second account.
*/
export class DemoJMAPClient implements IJMAPClient {
private data: DemoData;
private readonly profile: DemoProfile;
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;
@@ -36,15 +42,47 @@ export class DemoJMAPClient implements IJMAPClient {
private lastStates: AccountStates = {};
private incomingTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
this.data = getDemoData();
constructor(profile: DemoProfile = 'work') {
this.profile = profile;
this.data = getDemoData(profile);
// 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) this.deriveHeaderFields(email);
this.recalcMailboxCounts();
for (const s of this.data.scheduled) {
const id = generateDemoId('submission');
this.scheduledSubmissions.set(id, { id, emailId: s.emailId, identityId: s.identityId, sendAt: s.sendAt, undoStatus: 'pending', isSmime: false });
}
}
private deriveHeaderFields(email: Email): void {
const headers = email.headers;
if (!headers) return;
const header = (name: string) => {
const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
return key ? headers[key] : undefined;
};
const auth = header('authentication-results');
if (auth && !email.authenticationResults) email.authenticationResults = parseAuthenticationResults(auth);
const spam = header('x-spam-score') ?? header('x-spam-status');
if (typeof spam === 'string' && email.spamScore === undefined) {
const parsed = parseSpamScore(spam);
if (parsed) { email.spamScore = parsed.score; email.spamStatus = parsed.status; }
}
const llm = header('x-spam-llm');
if (typeof llm === 'string' && !email.spamLLM) {
const parsed = parseSpamLLM(llm);
if (parsed) email.spamLLM = parsed;
}
}
// ── Connection lifecycle ──────────────────────────────────────
async connect(): Promise<void> {
// Start simulated incoming email timer
this.startIncomingEmailTimer();
// Simulated incoming mail, unless a screenshot run switched it off.
let simulate = true;
try { simulate = typeof localStorage === 'undefined' || localStorage.getItem(DEMO_SIMULATE_KEY) !== 'off'; } catch { /* storage blocked */ }
if (simulate) this.startIncomingEmailTimer();
}
disconnect(): void {
@@ -64,11 +102,14 @@ export class DemoJMAPClient implements IJMAPClient {
upgradeToBearer(): void { /* no-op */ }
enableTotpReauth(): void { /* no-op */ }
updateBasicAuth(): void { /* no-op */ }
getAccountId(): string { return 'demo-account'; }
getUsername(): string { return 'demo@example.com'; }
getAccountId(): string { return this.data.accountId; }
getUsername(): string { return this.data.username; }
// ── Capabilities ──────────────────────────────────────────────
/** The personal login is mail only; everything else lives in the work account. */
private get mailOnly(): boolean { return this.profile === 'personal'; }
hasAccountCapability(capability: string, _accountId?: string): boolean {
return capability === 'urn:ietf:params:jmap:submission';
}
@@ -79,11 +120,13 @@ export class DemoJMAPClient implements IJMAPClient {
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:vacationresponse': {},
'urn:ietf:params:jmap:contacts': {},
'urn:ietf:params:jmap:calendars': {},
'urn:ietf:params:jmap:sieve': {},
'urn:ietf:params:jmap:quota': {},
'urn:ietf:params:jmap:files': {},
...(this.mailOnly ? {} : {
'urn:ietf:params:jmap:contacts': {},
'urn:ietf:params:jmap:calendars': {},
'urn:ietf:params:jmap:sieve': {},
'urn:ietf:params:jmap:files': {},
}),
};
}
@@ -98,10 +141,10 @@ export class DemoJMAPClient implements IJMAPClient {
supportsQuota(): boolean { return true; }
supportsVacationResponse(): boolean { return true; }
accountSupports(): boolean { return true; }
supportsContacts(): boolean { return true; }
supportsCalendars(): boolean { return true; }
supportsSieve(): boolean { return true; }
supportsFiles(): boolean { return true; }
supportsContacts(): boolean { return !this.mailOnly; }
supportsCalendars(): boolean { return !this.mailOnly; }
supportsSieve(): boolean { return !this.mailOnly; }
supportsFiles(): boolean { return !this.mailOnly; }
supportsPrincipals(): boolean { return false; }
async getPrincipals(): Promise<never[]> { return []; }
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
@@ -134,13 +177,38 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Quota ─────────────────────────────────────────────────────
async getQuota(): Promise<StorageQuota | null> {
return { used: 245_366_784, total: 1_073_741_824 };
return { ...this.data.quota };
}
// ── Mailboxes ─────────────────────────────────────────────────
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getMailboxes(accountId?: string): Promise<Mailbox[]> {
if (accountId && accountId !== this.data.accountId) {
return [...(this.data.sharedMailboxes[accountId]?.mailboxes ?? [])];
}
return [...this.data.mailboxes];
}
/**
* Own folders plus the folders of accounts shared with this login, the
* latter namespaced `<ownerId>:<id>` and tagged `isShared`, as
* JMAPClient.getAllMailboxes does. Mail keeps the bare ids in mailboxIds;
* the store queries shared folders by `originalId` + owner `accountId`.
*/
async getAllMailboxes(): Promise<Mailbox[]> {
const shared = Object.entries(this.data.sharedMailboxes).flatMap(([accountId, { name, mailboxes }]) =>
mailboxes.map((mb) => ({
...mb,
id: `${accountId}:${mb.id}`,
originalId: mb.id,
parentId: mb.parentId ? `${accountId}:${mb.parentId}` : undefined,
accountId,
accountName: name,
isShared: true,
})),
);
return [...this.data.mailboxes, ...shared];
}
async createMailbox(name: string, parentId?: string, _accountId?: string, options?: { role?: string }): Promise<Mailbox> {
const mb: Mailbox = {
@@ -207,11 +275,26 @@ export class DemoJMAPClient implements IJMAPClient {
(f.email || '').toLowerCase().includes(q) || (f.name || '').toLowerCase().includes(q));
if (!match) return false;
}
const addressMatch = (list: EmailAddress[] | undefined, q: string) =>
(list || []).some(a => (a.email || '').toLowerCase().includes(q) || (a.name || '').toLowerCase().includes(q));
if (typeof filter.to === 'string' && !addressMatch(e.to, filter.to.toLowerCase())) return false;
if (typeof filter.cc === 'string' && !addressMatch(e.cc, filter.cc.toLowerCase())) return false;
if (typeof filter.bcc === 'string' && !addressMatch(e.bcc, filter.bcc.toLowerCase())) return false;
if (typeof filter.subject === 'string' && !(e.subject || '').toLowerCase().includes(filter.subject.toLowerCase())) return false;
if (typeof filter.hasAttachment === 'boolean' && e.hasAttachment !== filter.hasAttachment) return false;
if (typeof filter.before === 'string' && e.receivedAt >= filter.before) return false;
if (typeof filter.after === 'string' && e.receivedAt < filter.after) return false;
if (typeof filter.text === 'string') {
const q = filter.text.toLowerCase();
const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email].filter(Boolean).join(' ').toLowerCase();
const bodyText = Object.values(e.bodyValues || {}).map(v => v.value).join(' ');
const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email, bodyText].filter(Boolean).join(' ').toLowerCase();
if (!text.includes(q)) return false;
}
if (typeof filter.body === 'string') {
const q = filter.body.toLowerCase();
const bodyText = Object.values(e.bodyValues || {}).map(v => v.value).join(' ').toLowerCase();
if (!bodyText.includes(q)) return false;
}
if (typeof filter.inMailbox === 'string' && !e.mailboxIds[filter.inMailbox]) return false;
if (Array.isArray(filter.inMailboxOtherThan)) {
const excluded = new Set(filter.inMailboxOtherThan as string[]);
@@ -654,11 +737,11 @@ export class DemoJMAPClient implements IJMAPClient {
const email: Email = {
id, threadId: generateDemoId('thread'),
mailboxIds: { [draftsMb?.id || 'demo-mailbox-drafts']: true },
mailboxIds: { [draftsMb?.id || this.data.mailboxes[0].id]: true },
keywords: { $seen: true, $draft: true },
size: body.length,
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [this.fromAddress(_identityId, _fromEmail, _fromName)],
to: to.map(e => ({ email: e })),
cc: cc?.length ? cc.map(e => ({ email: e })) : undefined,
bcc: bcc?.length ? bcc.map(e => ({ email: e })) : undefined,
@@ -710,11 +793,11 @@ export class DemoJMAPClient implements IJMAPClient {
const sentMb = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: generateDemoId('email'), threadId: generateDemoId('thread'),
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: true },
mailboxIds: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || this.data.mailboxes[0].id) : (sentMb?.id || this.data.mailboxes[0].id)]: true },
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: body.length + (htmlBody?.length || 0),
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [this.fromAddress(_identityId, _fromEmail, _fromName)],
to: to.map(e => ({ email: e })),
cc: cc?.length ? cc.map(e => ({ email: e })) : undefined,
bcc: bcc?.length ? bcc.map(e => ({ email: e })) : undefined,
@@ -737,7 +820,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.scheduledSubmissions.set(emailSubmissionId, {
id: emailSubmissionId,
emailId: email.id,
identityId: _identityId || 'demo-identity',
identityId: _identityId || this.data.identities[0]?.id || 'demo-identity',
sendAt: delayedUntil,
undoStatus: 'pending',
isSmime: false,
@@ -749,6 +832,14 @@ export class DemoJMAPClient implements IJMAPClient {
: { scheduled: false, emailId: email.id };
}
/** The From address of new mail: the chosen identity, else the account's own address. */
private fromAddress(identityId?: string, fromEmail?: string, fromName?: string): EmailAddress {
const identity = identityId ? this.data.identities.find(i => i.id === identityId) : undefined;
if (identity) return { name: identity.name, email: identity.email };
if (fromEmail) return { name: fromName || this.data.self.name, email: fromEmail };
return { ...this.data.self };
}
async sendImipReply(): Promise<void> { /* no-op in demo */ }
async sendImipInvitation(): Promise<void> { /* no-op in demo */ }
async sendImipCancellation(): Promise<void> { /* no-op in demo */ }
@@ -784,6 +875,9 @@ export class DemoJMAPClient implements IJMAPClient {
async fetchBlob(blobId: string): Promise<Blob> {
const stored = this.blobStore.get(blobId);
if (stored) return stored;
if (blobId === GONOGO_INVITE_BLOB) {
return new Blob([createGoNoGoInvitationIcs()], { type: 'text/calendar; method=REQUEST' });
}
const asset = DEMO_BLOB_ASSETS[blobId];
if (asset) {
try {
@@ -865,7 +959,7 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Contacts ──────────────────────────────────────────────────
getContactsAccountId(): string { return 'demo-account'; }
getContactsAccountId(): string { return this.data.accountId; }
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
@@ -906,7 +1000,7 @@ export class DemoJMAPClient implements IJMAPClient {
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
const full: ContactCard = {
id: generateDemoId('contact'),
addressBookIds: contact.addressBookIds ?? { 'demo-addressbook-personal': true },
addressBookIds: contact.addressBookIds ?? { [(this.data.addressBooks.find(b => b.isDefault) ?? this.data.addressBooks[0])?.id ?? 'demo-addressbook-personal']: true },
...contact,
} as ContactCard;
this.data.contacts.push(full);
@@ -933,7 +1027,7 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Calendars ─────────────────────────────────────────────────
getCalendarsAccountId(): string { return 'demo-account'; }
getCalendarsAccountId(): string { return this.data.accountId; }
async getCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
async getAllCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
@@ -990,7 +1084,7 @@ export class DemoJMAPClient implements IJMAPClient {
async createCalendarEvent(event: Partial<CalendarEvent>): Promise<CalendarEvent> {
const full: CalendarEvent = {
id: generateDemoId('event'),
calendarIds: event.calendarIds ?? { 'demo-calendar-personal': true },
calendarIds: event.calendarIds ?? { [(this.data.calendars.find(c => c.isDefault) ?? this.data.calendars[0])?.id ?? 'demo-calendar-work']: true },
'@type': 'Event',
uid: generateDemoId('uid'),
title: event.title ?? 'New Event',
@@ -1029,10 +1123,27 @@ export class DemoJMAPClient implements IJMAPClient {
return { created, failed: [], notCreated: {} };
}
/**
* Applies a CalendarEvent/set patch. Keys may be JSON pointers
* (`participants/p2/participationStatus`), which is how the store sends
* an RSVP; plain keys replace the property.
*/
async updateCalendarEvent(eventId: string, updates: Partial<CalendarEvent>): Promise<void> {
const event = this.data.calendarEvents.find(e => e.id === eventId);
if (!event) throw new Error('Event not found');
Object.assign(event, updates, { updated: new Date().toISOString() });
const target = event as unknown as Record<string, unknown>;
for (const [key, value] of Object.entries(updates as Record<string, unknown>)) {
if (!key.includes('/')) { target[key] = value; continue; }
const path = key.split('/').map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
let node = target;
for (const segment of path.slice(0, -1)) {
if (typeof node[segment] !== 'object' || node[segment] === null) node[segment] = {};
node = node[segment] as Record<string, unknown>;
}
const last = path[path.length - 1];
if (value === null) delete node[last]; else node[last] = value;
}
event.updated = new Date().toISOString();
}
async deleteCalendarEvent(eventId: string): Promise<void> {
@@ -1047,6 +1158,7 @@ export class DemoJMAPClient implements IJMAPClient {
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
return this.data.calendarEvents.filter(e => {
if (filter.uid && e.uid !== filter.uid) return false;
if (filter.after && e.start < filter.after) return false;
if (filter.before && e.start > filter.before) return false;
const q = (filter.text ?? filter.title)?.toLowerCase();
@@ -1062,8 +1174,11 @@ export class DemoJMAPClient implements IJMAPClient {
return this.queryCalendarEvents(filter);
}
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
return []; // no-op in demo
/** Only the go/no-go invitation carries a parsable .ics in the demo. */
async parseCalendarEvents(_accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]> {
if (blobId !== GONOGO_INVITE_BLOB) return [];
const event = this.data.calendarEvents.find(e => e.id === GONOGO_EVENT_ID);
return event ? [JSON.parse(JSON.stringify(event))] : [];
}
// ── Calendar Tasks ────────────────────────────────────────────
@@ -1119,15 +1234,10 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Sieve / Filters ──────────────────────────────────────────
getSharedAccounts(): SharedAccount[] {
return [{
id: 'demo-account',
name: 'Demo',
isPrimary: true,
capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true },
}];
return demoSharedAccounts(this.profile);
}
getSieveAccountId(): string { return 'demo-account'; }
getSieveAccountId(): string { return this.data.accountId; }
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
return this.getSharedAccounts()
@@ -1192,7 +1302,7 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Files (FileNode) ─────────────────────────────────────────
getFilesAccountId(): string { return 'demo-account'; }
getFilesAccountId(): string { return this.data.accountId; }
async probeFileNodeSupport(): Promise<boolean> { return true; }
@@ -1281,18 +1391,18 @@ export class DemoJMAPClient implements IJMAPClient {
}
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
}
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity-primary', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
const sentMailbox = this.data.mailboxes.find(m => m.role === 'sent');
const email: Email = {
id: emailId,
threadId: generateDemoId('thread'),
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || 'demo-mailbox-sent']: true },
mailboxIds: { [(delayedUntil ? draftsMailbox?.id : sentMailbox?.id) || this.data.mailboxes[0].id]: true },
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
size: 1024,
receivedAt: new Date().toISOString(),
from: [{ name: 'Demo User', email: 'demo@example.com' }],
from: [this.fromAddress(identityId)],
to: [],
subject: 'S/MIME message',
preview: 'Signed/encrypted demo message',
@@ -1355,7 +1465,8 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Internal helpers ──────────────────────────────────────────
private recalcMailboxCounts(): void {
for (const mb of this.data.mailboxes) {
const shared = Object.values(this.data.sharedMailboxes).flatMap((s) => s.mailboxes);
for (const mb of [...this.data.mailboxes, ...shared]) {
const inMb = this.data.emails.filter(e => e.mailboxIds[mb.id]);
mb.totalEmails = inMb.length;
mb.unreadEmails = inMb.filter(e => !e.keywords.$seen).length;
@@ -1385,19 +1496,16 @@ export class DemoJMAPClient implements IJMAPClient {
}
private simulateIncomingEmail(): void {
const senders = [
{ name: 'Alice Johnson', email: 'alice.johnson@example.com' },
{ name: 'Bob Chen', email: 'bob.chen@example.com' },
{ name: 'Sarah Kim', email: 'sarah.kim@example.com' },
{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' },
];
const inbox = this.data.mailboxes.find(m => m.role === 'inbox');
if (!inbox || this.profile !== 'work') return;
const senders = [ALICE, BOB, SARAH, PRIYA, LISA].map(p => ({ name: p.name, email: p.email }));
const subjects = [
'Quick question about the project',
'Meeting rescheduled to tomorrow',
'FYI: Updated documentation',
'Can you review this PR?',
'Quick question about the canary list',
'Go/no-go moved by 15 minutes',
'FYI: runbook comments addressed',
'Can you look at #1291 before lunch?',
'Lunch today?',
'Important: deadline reminder',
'Reminder: changelog quote',
];
const sender = senders[Math.floor(Math.random() * senders.length)];
@@ -1406,12 +1514,12 @@ export class DemoJMAPClient implements IJMAPClient {
const email: Email = {
id, threadId: generateDemoId('thread'),
mailboxIds: { 'demo-mailbox-inbox': true },
mailboxIds: { [inbox.id]: true },
keywords: {},
size: 1800,
receivedAt: new Date().toISOString(),
from: [sender],
to: [{ name: 'Demo User', email: 'demo@example.com' }],
to: [{ ...this.data.self }],
subject, sentAt: new Date().toISOString(),
preview: `Hi, ${subject.toLowerCase()}. Let me know what you think.`,
hasAttachment: false,
@@ -1428,7 +1536,7 @@ export class DemoJMAPClient implements IJMAPClient {
// Notify state change to trigger UI refresh
this.stateChangeCallback?.({
'@type': 'StateChange',
changed: { 'demo-account': { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } },
changed: { [this.data.accountId]: { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } },
});
}
}
+75 -5
View File
@@ -1,6 +1,8 @@
import { cloneFixtures } from './demo-utils';
import { createDemoMailboxes } from './fixtures/mailboxes';
import { createDemoMailboxes, createDemoSupportMailboxes, createDemoPersonalMailboxes, SUPPORT_ACCOUNT_ID, SUPPORT_ACCOUNT_NAME } from './fixtures/mailboxes';
import { createDemoEmails } from './fixtures/emails';
import { createDemoSupportEmails } from './fixtures/support';
import { createDemoPersonalEmails, createDemoPersonalIdentities } from './fixtures/personal';
import { createDemoContacts, createDemoAddressBooks } from './fixtures/contacts';
import { createDemoCalendars, createDemoCalendarEvents } from './fixtures/calendars';
import { createDemoCalendarTasks } from './fixtures/tasks';
@@ -8,12 +10,29 @@ import { createDemoIdentities } from './fixtures/identities';
import { createDemoSieveScripts, createDemoSieveCapabilities, createDemoSieveContent } from './fixtures/filters';
import { createDemoFileNodes } from './fixtures/files';
import { createDemoVacationResponse } from './fixtures/vacation';
import { NORA, NORA_PERSONAL } from './fixtures/people';
import type { Email, Mailbox, ContactCard, AddressBook, Calendar, CalendarEvent, CalendarTask, Identity, VacationResponse, FileNode } from '@/lib/jmap/types';
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';
/**
* The demo signs in to two accounts. "work" is the primary login with mail,
* contacts, calendars, files and filters, plus the shared Support mailbox
* delegated to it. "personal" is a mail-only second login, so the account
* switcher and the merged inbox have more than one source.
*/
export type DemoProfile = 'work' | 'personal';
export interface DemoData {
profile: DemoProfile;
/** JMAP account id of the login's primary account. */
accountId: string;
username: string;
/** The persona's own address in this account, used as From on new mail. */
self: EmailAddress;
mailboxes: Mailbox[];
/** Mailboxes of accounts shared with this login, keyed by their owner. */
sharedMailboxes: Record<string, { name: string; mailboxes: Mailbox[] }>;
emails: Email[];
contacts: ContactCard[];
addressBooks: AddressBook[];
@@ -26,13 +45,62 @@ export interface DemoData {
sieveContent: Record<string, string>;
fileNodes: FileNode[];
vacationResponse: VacationResponse;
quota: { used: number; total: number };
/** Ids of drafts that are scheduled to go out, with their send time. */
scheduled: Array<{ emailId: string; identityId: string; sendAt: string }>;
}
/** Return a fresh deep-cloned copy of all demo data. */
export function getDemoData(): DemoData {
export const DEMO_WORK_ACCOUNT_ID = 'demo-account';
export const DEMO_PERSONAL_ACCOUNT_ID = 'demo-personal';
/** What `getSharedAccounts()` reports for a profile. */
export function demoSharedAccounts(profile: DemoProfile): SharedAccount[] {
if (profile === 'personal') {
return [{ id: DEMO_PERSONAL_ACCOUNT_ID, name: 'Personal', isPrimary: true, capabilities: { mail: true, sieve: false, calendars: false, contacts: false, filenode: false } }];
}
return [
{ id: DEMO_WORK_ACCOUNT_ID, name: 'Meridian Labs', isPrimary: true, capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true } },
{ id: SUPPORT_ACCOUNT_ID, name: SUPPORT_ACCOUNT_NAME, isPrimary: false, capabilities: { mail: true, sieve: false, calendars: false, contacts: false, filenode: false } },
];
}
/** Return a fresh deep-cloned copy of all demo data for one profile. */
export function getDemoData(profile: DemoProfile = 'work'): DemoData {
if (profile === 'personal') {
return cloneFixtures({
profile,
accountId: DEMO_PERSONAL_ACCOUNT_ID,
username: NORA_PERSONAL.email,
self: { name: NORA_PERSONAL.name, email: NORA_PERSONAL.email },
mailboxes: createDemoPersonalMailboxes(),
sharedMailboxes: {},
emails: createDemoPersonalEmails(),
contacts: [],
addressBooks: [],
calendars: [],
calendarEvents: [],
calendarTasks: [],
identities: createDemoPersonalIdentities(),
sieveScripts: [],
sieveCapabilities: createDemoSieveCapabilities(),
sieveContent: {},
fileNodes: [],
vacationResponse: { id: 'singleton', isEnabled: false, fromDate: null, toDate: null, subject: '', textBody: '', htmlBody: null },
quota: { used: 1_814_000_000, total: 10_737_418_240 },
scheduled: [],
});
}
const scheduledAt = new Date();
scheduledAt.setHours(12, 0, 0, 0);
if (scheduledAt.getTime() < Date.now()) scheduledAt.setDate(scheduledAt.getDate() + 1);
return cloneFixtures({
profile,
accountId: DEMO_WORK_ACCOUNT_ID,
username: NORA.email,
self: { name: NORA.name, email: NORA.email },
mailboxes: createDemoMailboxes(),
emails: createDemoEmails(),
sharedMailboxes: { [SUPPORT_ACCOUNT_ID]: { name: SUPPORT_ACCOUNT_NAME, mailboxes: createDemoSupportMailboxes() } },
emails: [...createDemoEmails(), ...createDemoSupportEmails()],
contacts: createDemoContacts(),
addressBooks: createDemoAddressBooks(),
calendars: createDemoCalendars(),
@@ -44,5 +112,7 @@ export function getDemoData(): DemoData {
sieveContent: createDemoSieveContent(),
fileNodes: createDemoFileNodes(),
vacationResponse: createDemoVacationResponse(),
quota: { used: 6_442_450_944, total: 21_474_836_480 },
scheduled: [{ emailId: 'demo-email-scheduled-prep', identityId: 'demo-identity-primary', sendAt: scheduledAt.toISOString() }],
});
}
+40
View File
@@ -0,0 +1,40 @@
import { useSettingsStore } from '@/stores/settings-store';
import { useTemplateStore } from '@/stores/template-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEMO_KEYWORDS } from './fixtures/keywords';
import { createDemoTemplates } from './fixtures/templates';
/** Bump when the seeded preferences change, so returning visitors get them. */
const SEED_VERSION = '2026-10';
export const DEMO_SEED_KEY = 'bulwark-demo-seed';
/**
* Device-local preferences the demo needs to show its data: the persona's
* tags, tasks switched on, the merged inbox across both demo accounts, and
* a few templates. Runs once per device per version, so a visitor's own
* changes survive a reload; "Reset demo" clears storage and seeds again.
*/
export function seedDemoPreferences(): void {
// The merged views across accounts are gated off by default and nobody
// administers the demo; the demo's second account and shared mailbox exist
// for those views, so switch the gate on for this session.
usePolicyStore.getState().setDemoFeatureOverrides({ unifiedCrossAccountEnabled: true });
let seeded: string | null = null;
try { seeded = localStorage.getItem(DEMO_SEED_KEY); } catch { /* storage blocked */ }
if (seeded === SEED_VERSION) return;
const settings = useSettingsStore.getState();
settings.updateSetting('emailKeywords', DEMO_KEYWORDS);
settings.updateSetting('enableCalendarTasks', true);
settings.updateSetting('enableUnifiedMailbox', true);
settings.updateSetting('unifiedCrossAccount', true);
settings.updateSetting('includeGroupInUnified', true);
const templates = useTemplateStore.getState();
if (templates.templates.length === 0) {
useTemplateStore.setState({ templates: createDemoTemplates() });
}
try { localStorage.setItem(DEMO_SEED_KEY, SEED_VERSION); } catch { /* storage blocked */ }
}
+12
View File
@@ -29,6 +29,18 @@ export function demoISODate(daysOffset: number, hours: number = 0, minutes: numb
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:00`;
}
/**
* The UTC instant of a local wall-clock time on a day relative to today
* (`demoAt(1, 14)` is tomorrow 14:00 local, as an ISO string). Pairs with
* demoISODate for JSCalendar utcStart/utcEnd.
*/
export function demoAt(daysOffset: number, hours: number = 0, minutes: number = 0): string {
const d = new Date();
d.setDate(d.getDate() + daysOffset);
d.setHours(hours, minutes, 0, 0);
return d.toISOString();
}
/** Deep clone fixture data so in-memory mutations don't corrupt originals. */
export function cloneFixtures<T>(data: T): T {
return JSON.parse(JSON.stringify(data));
+58
View File
@@ -0,0 +1,58 @@
/**
* Files under public/demo/ with real bytes, keyed by blob id. Every
* attachment and every file in the demo Files app points at one of these,
* so previews show real content. The sizes are the files' byte counts; the
* fixture test checks they match. Regenerate the generated ones with
* `node scripts/demo-assets/generate.mjs`.
*
* The photos are CC0 from Wikimedia Commons ("Wedding couple (1294004)",
* "Wedding couple (1293575)", "Men drinking", "Lake Mountain Landscape",
* "Team members of startup"); prototype-v3.png is drawn for the demo; the
* rest is rendered by the generator.
*/
export interface DemoAsset {
path: string;
name: string;
type: string;
size: number;
}
export const DEMO_ASSETS = {
'demo-blob-invoice-designco': { path: '/demo/invoice-designco-2026-089.pdf', name: 'Invoice-2026-089.pdf', type: 'application/pdf', size: 50800 },
'demo-blob-prototype': { path: '/demo/prototype-v3.png', name: 'prototype-v3.png', type: 'image/png', size: 21864 },
'demo-blob-hotel-voucher': { path: '/demo/hotel-voucher-lisbon.pdf', name: 'Booking-4892771433-voucher.pdf', type: 'application/pdf', size: 51467 },
'demo-blob-boarding-pass': { path: '/demo/boarding-pass-ber-lis.pdf', name: 'Boarding-pass-LH1796.pdf', type: 'application/pdf', size: 53751 },
'demo-blob-summit-ticket': { path: '/demo/infra-summit-ticket.pdf', name: 'Infra-Summit-ticket-IS26-SPK-01827.pdf', type: 'application/pdf', size: 93744 },
'demo-blob-launch-plan': { path: '/demo/beacon-launch-plan-v3.pdf', name: 'Beacon-launch-plan-v3.pdf', type: 'application/pdf', size: 44032 },
'demo-blob-hetzner-invoice': { path: '/demo/hetzner-invoice.pdf', name: 'Hetzner-R0012345678.pdf', type: 'application/pdf', size: 39366 },
'demo-blob-quarterly-report': { path: '/demo/quarterly-report.pdf', name: 'quarterly-report.pdf', type: 'application/pdf', size: 40644 },
'demo-blob-po-lumen': { path: '/demo/po-lumen-logistics.pdf', name: 'PO-2026-0412-Lumen-Logistics.pdf', type: 'application/pdf', size: 39255 },
'demo-blob-soak-dashboard': { path: '/demo/beacon-soak-dashboard.png', name: 'beacon-soak-dashboard.png', type: 'image/png', size: 110149 },
'demo-blob-book-cover': { path: '/demo/book-cover-draft.png', name: 'lemon-tree-cover-draft2.png', type: 'image/png', size: 107537 },
'demo-blob-soak-metrics': { path: '/demo/beacon-soak-metrics.xlsx', name: 'beacon-soak-metrics.xlsx', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 8731 },
'demo-blob-budget': { path: '/demo/budget-2026.xlsx', name: 'budget-2026.xlsx', type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', size: 6835 },
'demo-blob-runbook': { path: '/demo/beacon-rollback-runbook.docx', name: 'Beacon-rollback-runbook.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', size: 8303 },
'demo-blob-service-agreement': { path: '/demo/service-agreement-draft.docx', name: 'service-agreement-draft.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', size: 7787 },
'demo-blob-launch-deck': { path: '/demo/beacon-launch-deck.pptx', name: 'Beacon-launch.pptx', type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', size: 99675 },
'demo-blob-meeting-notes': { path: '/demo/meeting-notes.md', name: 'meeting-notes.md', type: 'text/markdown', size: 560 },
'demo-blob-todo': { path: '/demo/todo.txt', name: 'todo.txt', type: 'text/plain', size: 275 },
'demo-blob-wedding-1': { path: '/demo/wedding-001.jpg', name: 'wedding-001.jpg', type: 'image/jpeg', size: 170392 },
'demo-blob-wedding-2': { path: '/demo/wedding-014-mom-dad.jpg', name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg', size: 154740 },
'demo-blob-wedding-3': { path: '/demo/wedding-038-the-toast.jpg', name: 'wedding-038-the-toast.jpg', type: 'image/jpeg', size: 133897 },
'demo-blob-vacation': { path: '/demo/vacation.jpg', name: 'lake-como.jpg', type: 'image/jpeg', size: 91949 },
'demo-blob-team-photo': { path: '/demo/team-photo.jpg', name: 'team-offsite.jpg', type: 'image/jpeg', size: 71066 },
} as const satisfies Record<string, DemoAsset>;
export type DemoBlobId = keyof typeof DEMO_ASSETS;
/** An Email attachment entry for one of the assets. */
export function attachment(blobId: DemoBlobId, overrides: { name?: string; partId?: string } = {}) {
const asset = DEMO_ASSETS[blobId];
return {
partId: overrides.partId ?? blobId.replace('demo-blob-', 'att-'),
blobId,
size: asset.size,
name: overrides.name ?? asset.name,
type: asset.type,
};
}
+231 -260
View File
@@ -1,272 +1,243 @@
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { demoDate, demoISODate } from '../demo-utils';
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import { demoAt, demoDate, demoISODate } from '../demo-utils';
import { type Person, NORA, ALICE, BOB, PRIYA, AHMED, LISA, MICHAEL, SARAH, CARLOS, MARCUS, EMMA, DAVID, KENJI, JAMES } from './people';
export const CAL = {
work: 'demo-calendar-work',
personal: 'demo-calendar-personal',
birthdays: 'demo-calendar-birthdays',
team: 'demo-calendar-team',
} as const;
const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
const rights = (mayDelete: boolean, mayWriteAll = true) => ({
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: mayWriteAll, mayDelete,
});
export function createDemoCalendars(): Calendar[] {
const base = { description: null, isSubscribed: true, isVisible: true, includeInAvailability: 'all' as const, defaultAlertsWithTime: null, defaultAlertsWithoutTime: null, timeZone: null, shareWith: null };
return [
{
id: 'demo-calendar-personal',
name: 'Personal',
description: null,
color: '#3b82f6',
sortOrder: 1,
isSubscribed: true,
isVisible: true,
isDefault: true,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: false },
},
{
id: 'demo-calendar-work',
name: 'Work',
description: null,
color: '#22c55e',
sortOrder: 2,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'all',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
},
{
id: 'demo-calendar-birthdays',
name: 'Birthdays',
description: null,
color: '#eab308',
sortOrder: 3,
isSubscribed: true,
isVisible: true,
isDefault: false,
includeInAvailability: 'none',
defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null,
timeZone: null,
shareWith: null,
myRights: { mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true },
},
{ ...base, id: CAL.work, name: 'Work', color: '#db2d54', sortOrder: 1, isDefault: true, myRights: rights(false) },
{ ...base, id: CAL.personal, name: 'Personal', color: '#3b82f6', sortOrder: 2, isDefault: false, myRights: rights(true) },
{ ...base, id: CAL.team, name: 'Beacon team', description: 'Shared by Alice Johnson', color: '#8b5cf6', sortOrder: 3, isDefault: false, myRights: rights(false, false) },
{ ...base, id: CAL.birthdays, name: 'Birthdays', color: '#eab308', sortOrder: 4, isDefault: false, includeInAvailability: 'none', myRights: rights(true) },
];
}
// ── Participants ──────────────────────────────────────────────
type Status = 'accepted' | 'tentative' | 'declined' | 'needs-action';
function participant(p: Person, roles: Record<string, boolean>, status: Status = 'accepted', expectReply = false): CalendarParticipant {
return {
'@type': 'Participant', name: p.name, email: p.email, calendarAddress: `mailto:${p.email}`, description: null,
sendTo: { imip: `mailto:${p.email}` }, kind: 'individual', roles, participationStatus: status, participationComment: null,
expectReply, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
locationId: null, language: null, links: null,
} as CalendarParticipant;
}
const owner = (p: Person) => participant(p, { owner: true, attendee: true });
const attendee = (p: Person, status: Status = 'accepted') => participant(p, { attendee: true }, status, status === 'needs-action');
/** Participants keyed p1, p2, ... */
const people = (...list: CalendarParticipant[]) => Object.fromEntries(list.map((p, i) => [`p${i + 1}`, p]));
// ── Events ────────────────────────────────────────────────────
const BASE = {
'@type': 'Event' as const,
descriptionContentType: 'text/plain',
isDraft: false,
isOrigin: true,
sequence: 0,
status: 'confirmed' as const,
freeBusyStatus: 'busy' as const,
privacy: 'public' as const,
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: true,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
};
interface Timed { day: number; hour: number; minute?: number; hours?: number; minutes?: number }
/** A timed event on a day relative to today. */
function timed(id: string, calendar: string, title: string, t: Timed, extra: Partial<CalendarEvent> = {}): CalendarEvent {
// `hours` and `minutes` are the length; `{ minutes: 15 }` alone is a quarter hour.
const minutes = (t.hours ?? (t.minutes === undefined ? 1 : 0)) * 60 + (t.minutes ?? 0);
const duration = minutes % 60 === 0 ? `PT${minutes / 60}H` : minutes < 60 ? `PT${minutes}M` : `PT${Math.floor(minutes / 60)}H${minutes % 60}M`;
return {
...BASE,
id, uid: `${id}@meridianlabs.example`,
calendarIds: { [calendar]: true },
title, description: '',
created: demoDate(-14), updated: demoDate(-2),
start: demoISODate(t.day, t.hour, t.minute ?? 0),
utcStart: demoAt(t.day, t.hour, t.minute ?? 0),
utcEnd: demoAt(t.day, t.hour, (t.minute ?? 0) + minutes),
duration, timeZone: TZ, showWithoutTime: false,
...extra,
} as CalendarEvent;
}
/** An all-day event spanning `days` days from a day relative to today. */
function allDay(id: string, calendar: string, title: string, day: number, days = 1, extra: Partial<CalendarEvent> = {}): CalendarEvent {
return {
...BASE,
id, uid: `${id}@meridianlabs.example`,
calendarIds: { [calendar]: true },
title, description: '',
created: demoDate(-30), updated: demoDate(-30),
start: demoISODate(day), utcStart: demoAt(day), utcEnd: demoAt(day + days),
duration: `P${days}D`, timeZone: null, showWithoutTime: true,
freeBusyStatus: 'free' as const,
...extra,
} as CalendarEvent;
}
const room = (name: string, description: string) => ({ loc1: { '@type': 'Location' as const, name, description, locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } });
const zoom = (id: string) => ({ vl1: { '@type': 'VirtualLocation' as const, name: 'Zoom', uri: `https://zoom.us/j/${id}`, description: null, features: null } });
type RruleDay = 'mo' | 'tu' | 'we' | 'th' | 'fr' | 'sa' | 'su';
const weekly = (day: RruleDay) => [{
'@type': 'RecurrenceRule' as const, frequency: 'weekly' as const, interval: 1, firstDayOfWeek: 'mo' as const, byDay: [{ day }],
byMonthDay: null, byMonth: null, byYearDay: null, byWeekNo: null, byHour: null, byMinute: null, bySecond: null, bySetPosition: null, count: null, until: null,
}];
/** Weekday of a day relative to today as a two-letter rrule day. */
function rruleDay(dayOffset: number): RruleDay {
const d = new Date();
d.setDate(d.getDate() + dayOffset);
return (['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const)[d.getDay()];
}
/** The go/no-go review, invited by Alice; Nora has not answered yet. */
export const GONOGO_EVENT_ID = 'demo-event-gonogo';
export function createDemoCalendarEvents(): CalendarEvent[] {
const baseEvent = {
'@type': 'Event' as const,
descriptionContentType: 'text/plain',
isDraft: false,
isOrigin: true,
sequence: 0,
status: 'confirmed' as const,
freeBusyStatus: 'busy' as const,
privacy: 'public' as const,
color: null,
keywords: null,
categories: null,
locale: null,
replyTo: null,
organizerCalendarAddress: null,
participants: null,
mayInviteSelf: false,
mayInviteOthers: false,
hideAttendees: false,
recurrenceId: null,
recurrenceIdTimeZone: null,
recurrenceRules: null,
recurrenceOverrides: null,
excludedRecurrenceRules: null,
useDefaultAlerts: true,
alerts: null,
locations: null,
virtualLocations: null,
links: null,
relatedTo: null,
};
const team = people(owner(ALICE), attendee(NORA), attendee(BOB), attendee(PRIYA), attendee(AHMED, 'tentative'), attendee(LISA));
return [
// ── Personal calendar ──────────────────────────────────────
{
...baseEvent,
id: 'demo-event-1',
calendarIds: { 'demo-calendar-personal': true },
uid: 'demo-event-1@example.com',
title: 'Dentist Appointment',
description: 'Regular checkup at Dr. Smith\'s office',
created: demoDate(-7),
updated: demoDate(-7),
start: demoISODate(2, 10, 0),
utcStart: demoDate(2, 10),
utcEnd: demoDate(2, 11),
duration: 'PT1H',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
locations: { loc1: { '@type': 'Location', name: 'Dr. Smith Dental Clinic', description: '123 Medical Plaza', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
},
{
...baseEvent,
id: 'demo-event-2',
calendarIds: { 'demo-calendar-personal': true },
uid: 'demo-event-2@example.com',
title: 'Birthday Party',
description: 'Emma\'s birthday celebration',
created: demoDate(-10),
updated: demoDate(-10),
start: demoISODate(5),
utcStart: demoDate(5),
utcEnd: demoDate(6),
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'free' as const,
},
{
...baseEvent,
id: 'demo-event-3',
calendarIds: { 'demo-calendar-personal': true },
uid: 'demo-event-3@example.com',
title: 'Weekend Trip',
description: 'Road trip to the mountains',
created: demoDate(-5),
updated: demoDate(-5),
start: demoISODate(8),
utcStart: demoDate(8),
utcEnd: demoDate(10),
duration: 'P2D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'busy' as const,
},
// ── Work ──────────────────────────────────────────────────
timed('demo-event-standup', CAL.team, 'Beacon standup', { day: 0, hour: 9, minute: 15, minutes: 15 }, {
description: 'Daily, 15 minutes, standing.', recurrenceRules: weekly(rruleDay(0) === 'sa' || rruleDay(0) === 'su' ? 'mo' : rruleDay(0)),
virtualLocations: zoom('93120044817'), participants: team, created: demoDate(-60),
}),
timed('demo-event-beacon-weekly', CAL.team, 'Beacon weekly', { day: 0, hour: 10, hours: 1 }, {
description: 'Status, blockers, decisions. Notes in Documents/meeting-notes.md.',
locations: room('Room Spree', '4th floor'), virtualLocations: zoom('93120044818'), participants: team,
}),
timed('demo-event-lunch-sarah', CAL.work, 'Lunch with Sarah: prototype v3', { day: 0, hour: 12, minute: 30, hours: 1 }, {
description: 'Walk through the hero options over lunch.', locations: room('Café Kranzler', 'Rosenthaler Straße 38'),
participants: people(owner(NORA), attendee(SARAH)),
}),
timed('demo-event-1on1-alice', CAL.work, '1:1 Alice', { day: 0, hour: 16, minutes: 30 }, {
recurrenceRules: weekly(rruleDay(0) === 'sa' || rruleDay(0) === 'su' ? 'tu' : rruleDay(0)), participants: people(owner(ALICE), attendee(NORA)), created: demoDate(-90),
}),
timed(GONOGO_EVENT_ID, CAL.work, 'Beacon go / no-go review', { day: 1, hour: 14, hours: 1 }, {
uid: 'beacon-gonogo-2026@meridianlabs.example',
description: '1. Soak test results (Ahmed)\n2. Rollback plan (Nora)\n3. Open bugs, BEA-412 in particular (Priya)\n4. Decision',
locations: room('Room Spree', '4th floor'), virtualLocations: zoom('93120044901'),
isOrigin: false, replyTo: { imip: `mailto:${ALICE.email}` }, organizerCalendarAddress: `mailto:${ALICE.email}`,
participants: people(owner(ALICE), attendee(NORA, 'needs-action'), attendee(BOB), attendee(PRIYA), attendee(AHMED), attendee(LISA, 'tentative'), attendee(MICHAEL)),
created: demoDate(-1, -1), updated: demoDate(-1, -1),
}),
timed('demo-event-coffee-bob', CAL.work, 'Coffee with Bob', { day: 1, hour: 13, minute: 15, minutes: 30 }, {
locations: room('Café Kranzler', 'Rosenthaler Straße 38'), participants: people(owner(BOB), attendee(NORA)),
}),
timed('demo-event-focus', CAL.work, 'Focus: rollback runbook v2', { day: 2, hour: 9, hours: 3 }, { description: 'No meetings. Work in Bob\'s comments.', freeBusyStatus: 'busy' }),
timed('demo-event-design-review', CAL.work, 'Website redesign: v3 review', { day: 2, hour: 14, hours: 1 }, {
participants: people(owner(NORA), attendee(SARAH), attendee(LISA)), virtualLocations: zoom('93120044950'),
}),
timed('demo-event-team-lunch', CAL.team, 'Team lunch', { day: 3, hour: 12, minute: 30, hours: 1, minutes: 30 }, {
locations: room('Markthalle Neun', 'Eisenbahnstraße 42'), participants: team,
}),
timed('demo-event-canary-kickoff', CAL.team, 'Canary kickoff: 5% of tenants', { day: 6, hour: 9, hours: 1 }, {
description: 'Flip the flag for the five canary tenants, then watch for 24 h.', participants: team, virtualLocations: zoom('93120044960'),
}),
allDay('demo-event-launch', CAL.team, 'Beacon general availability', 8, 1, { description: 'Blog post, changelog and status page go live at 10:00.', color: '#db2d54' }),
allDay('demo-event-lisbon', CAL.work, 'Infra Summit, Lisbon', 9, 4, {
description: 'LH 1796 out at 08:35, LH 1797 back. Hotel Tejo Vista, confirmation 4892.771.433.',
locations: room('Centro de Congressos de Lisboa', 'Praça das Indústrias 1'),
}),
timed('demo-event-talk', CAL.work, 'Talk: Rolling back without rolling over', { day: 11, hour: 11, minute: 30, minutes: 40 }, {
description: 'Auditorium 2. AV check 08:45.', locations: room('Auditorium 2', 'Centro de Congressos de Lisboa'), participants: people(owner(KENJI), attendee(NORA)),
}),
timed('demo-event-speakers-dinner', CAL.work, 'Speakers\' dinner', { day: 10, hour: 20, hours: 2 }, {
locations: room('Cervejaria Ramiro', 'Av. Alm. Reis 1H, Lisboa'), participants: people(owner(KENJI), attendee(NORA)),
}),
timed('demo-event-board', CAL.work, 'Board meeting: engineering update', { day: -2, hour: 15, hours: 1 }, {
participants: people(owner(JAMES), attendee(NORA), attendee(MICHAEL)), locations: room('Room Havel', '5th floor'),
}),
timed('demo-event-soak-start', CAL.team, 'Soak test start', { day: -6, hour: 8, minutes: 30 }, { participants: team }),
// ── Work calendar ──────────────────────────────────────────
{
...baseEvent,
id: 'demo-event-4',
calendarIds: { 'demo-calendar-work': true },
uid: 'demo-event-4@example.com',
title: 'Weekly Standup',
description: 'Team sync-up meeting',
created: demoDate(-30),
updated: demoDate(-1),
start: demoISODate(1, 9, 30),
utcStart: demoDate(1, 9, 30),
utcEnd: demoDate(1, 10, 0),
duration: 'PT30M',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
recurrenceRules: [{
'@type': 'RecurrenceRule',
frequency: 'weekly',
interval: 1,
firstDayOfWeek: 'mo',
byDay: [{ day: 'mo' }],
byMonthDay: null,
byMonth: null,
byYearDay: null,
byWeekNo: null,
byHour: null,
byMinute: null,
bySecond: null,
bySetPosition: null,
count: null,
until: null,
}],
virtualLocations: { vl1: { '@type': 'VirtualLocation', name: 'Zoom', uri: 'https://zoom.example/123456', description: 'Weekly standup room', features: null } },
},
{
...baseEvent,
id: 'demo-event-5',
calendarIds: { 'demo-calendar-work': true },
uid: 'demo-event-5@example.com',
title: 'Quarterly Review',
description: 'Q4 performance review and planning session',
created: demoDate(-14),
updated: demoDate(-3),
start: demoISODate(4, 14, 0),
utcStart: demoDate(4, 14),
utcEnd: demoDate(4, 16),
duration: 'PT2H',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
participants: {
p1: {
'@type': 'Participant', name: 'Demo User', email: 'demo@example.com', calendarAddress: null, description: null, sendTo: null,
kind: 'individual', roles: { attendee: true }, participationStatus: 'accepted', participationComment: null,
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
locationId: null, language: null, links: null,
},
p2: {
'@type': 'Participant', name: 'Alice Johnson', email: 'alice.johnson@example.com', calendarAddress: null, description: null, sendTo: null,
kind: 'individual', roles: { owner: true }, participationStatus: 'accepted', participationComment: null,
expectReply: false, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
locationId: null, language: null, links: null,
},
p3: {
'@type': 'Participant', name: 'Bob Chen', email: 'bob.chen@example.com', calendarAddress: null, description: null, sendTo: null,
kind: 'individual', roles: { attendee: true }, participationStatus: 'tentative', participationComment: null,
expectReply: true, scheduleAgent: 'server', scheduleForceSend: false, scheduleId: null, scheduleSequence: 0,
scheduleStatus: null, scheduleUpdated: null, invitedBy: null, delegatedTo: null, delegatedFrom: null, memberOf: null,
locationId: null, language: null, links: null,
},
},
},
{
...baseEvent,
id: 'demo-event-6',
calendarIds: { 'demo-calendar-work': true },
uid: 'demo-event-6@example.com',
title: 'Lunch Meeting with Sarah',
description: 'Design review over lunch',
created: demoDate(-3),
updated: demoDate(-3),
start: demoISODate(3, 12, 0),
utcStart: demoDate(3, 12),
utcEnd: demoDate(3, 13),
duration: 'PT1H',
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
showWithoutTime: false,
locations: { loc1: { '@type': 'Location', name: 'The Garden Bistro', description: '123 Oak Street', locationTypes: null, coordinates: null, timeZone: null, links: null, relativeTo: null } },
},
// ── Personal ──────────────────────────────────────────────
timed('demo-event-bookclub', CAL.personal, 'Book club', { day: 2, hour: 19, hours: 2 }, {
description: 'Finish Le Guin, pick the next one. Bring snacks.', locations: room('Marcus\'s place', 'Kastanienallee 80'),
participants: people(owner(MARCUS), attendee(NORA), attendee(EMMA), attendee(DAVID)),
}),
timed('demo-event-dinner-carlos', CAL.personal, 'Dinner with Carlos and co.', { day: 3, hour: 19, minute: 30, hours: 2, minutes: 30 }, {
locations: room('The Garden Bistro', 'Oranienstraße 12'), participants: people(owner(CARLOS), attendee(NORA)),
}),
timed('demo-event-flea-market', CAL.personal, 'Flea market, Mauerpark', { day: 4, hour: 11, hours: 2 }, { freeBusyStatus: 'free' }),
timed('demo-event-call-mom', CAL.personal, 'Call Mom', { day: 5, hour: 18, minutes: 45 }, { recurrenceRules: weekly(rruleDay(5)), created: demoDate(-200) }),
timed('demo-event-dentist', CAL.personal, 'Dentist: check-up', { day: 7, hour: 10, hours: 1 }, {
locations: room('Zahnarztpraxis Dr. Weber', 'Kastanienallee 21'),
}),
timed('demo-event-yoga', CAL.personal, 'Yoga', { day: -1, hour: 7, hours: 1 }, { recurrenceRules: weekly(rruleDay(-1)), created: demoDate(-120), freeBusyStatus: 'free' }),
// ── Birthdays calendar ─────────────────────────────────────
{
...baseEvent,
id: 'demo-event-7',
calendarIds: { 'demo-calendar-birthdays': true },
uid: 'demo-event-7@example.com',
title: 'Alice Johnson\'s Birthday',
description: '',
created: demoDate(-30),
updated: demoDate(-30),
start: demoISODate(12),
utcStart: demoDate(12),
utcEnd: demoDate(13),
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'free' as const,
},
{
...baseEvent,
id: 'demo-event-8',
calendarIds: { 'demo-calendar-birthdays': true },
uid: 'demo-event-8@example.com',
title: 'Carlos Rivera\'s Birthday',
description: '',
created: demoDate(-30),
updated: demoDate(-30),
start: demoISODate(-3),
utcStart: demoDate(-3),
utcEnd: demoDate(-2),
duration: 'P1D',
timeZone: null,
showWithoutTime: true,
freeBusyStatus: 'free' as const,
},
// ── Birthdays ─────────────────────────────────────────────
allDay('demo-event-bday-carlos', CAL.birthdays, 'Carlos Rivera\'s birthday', -3),
allDay('demo-event-bday-alice', CAL.birthdays, 'Alice Johnson\'s birthday', 12),
allDay('demo-event-bday-emma', CAL.birthdays, 'Emma Wilson\'s birthday', 5),
];
}
/**
* The iMIP request behind the go/no-go invitation mail. The demo client
* serves this as the .ics blob and returns the matching event from
* parseCalendarEvents, so the invitation banner can offer an RSVP.
*/
export function createGoNoGoInvitationIcs(): string {
const event = createDemoCalendarEvents().find((e) => e.id === GONOGO_EVENT_ID)!;
const stamp = (iso: string) => iso.replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
const attendees = Object.values(event.participants ?? {}).filter((p) => !p.roles.owner).map((p) =>
`ATTENDEE;CN=${p.name};ROLE=REQ-PARTICIPANT;PARTSTAT=${p.participationStatus.toUpperCase()};RSVP=TRUE:mailto:${p.email}`);
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Meridian Labs//Bulwark Demo//EN',
'METHOD:REQUEST',
'BEGIN:VEVENT',
`UID:${event.uid}`,
`DTSTAMP:${stamp(event.created ?? event.utcStart!)}`,
`DTSTART:${stamp(event.utcStart!)}`,
`DTEND:${stamp(event.utcEnd!)}`,
`SUMMARY:${event.title}`,
`DESCRIPTION:${(event.description ?? '').replace(/\n/g, '\\n')}`,
'LOCATION:Room Spree\\, 4th floor',
`ORGANIZER;CN=${ALICE.name}:mailto:${ALICE.email}`,
...attendees,
'SEQUENCE:0',
'STATUS:CONFIRMED',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
}
+173 -230
View File
@@ -1,20 +1,20 @@
import type { ContactCard, AddressBook } from '@/lib/jmap/types';
import {
type Person, portraitUri,
NORA, ALICE, BOB, PRIYA, AHMED, JAMES, MICHAEL, RACHEL, WEI, MARIA, LISA,
SARAH, OLIVIA, JENNIFER, KENJI, SOFIA, ANNA, ELIAS, CARLOS, MARCUS, EMMA, DAVID, DANIEL,
TOMAS, LENA, YUSUF,
} from './people';
// randomuser.me serves stable portrait URLs at
// https://randomuser.me/api/portraits/{men|women}/{0..99}.jpg
// See https://randomuser.me/documentation#howto - we use these directly
// rather than hitting the JSON API so the demo works offline.
const portrait = (gender: 'men' | 'women', n: number): string =>
`https://randomuser.me/api/portraits/${gender}/${n}.jpg`;
const photo = (gender: 'men' | 'women', n: number) => ({
photo1: { kind: 'photo' as const, uri: portrait(gender, n), mediaType: 'image/jpeg' },
});
export const AB = {
personal: 'demo-addressbook-personal',
work: 'demo-addressbook-work',
} as const;
export function createDemoAddressBooks(): AddressBook[] {
return [
{
id: 'demo-addressbook-personal',
id: AB.personal,
name: 'Personal',
isDefault: true,
isSubscribed: true,
@@ -22,8 +22,8 @@ export function createDemoAddressBooks(): AddressBook[] {
myRights: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: false },
},
{
id: 'demo-addressbook-work',
name: 'Work',
id: AB.work,
name: 'Meridian Labs',
isDefault: false,
isSubscribed: true,
sortOrder: 2,
@@ -32,239 +32,182 @@ export function createDemoAddressBooks(): AddressBook[] {
];
}
type Extra = Partial<Omit<ContactCard, 'id' | 'addressBookIds' | 'kind' | 'name' | 'emails' | 'media'>> & {
/** Further addresses: [address, context]. */
moreEmails?: Array<[string, 'work' | 'private']>;
};
function person(id: string, book: string, p: Person, context: 'work' | 'private', extra: Extra = {}): ContactCard {
const { moreEmails, ...rest } = extra;
const photo = portraitUri(p);
const emails: ContactCard['emails'] = { e1: { address: p.email, contexts: { [context]: true }, pref: 1 } };
moreEmails?.forEach(([address, ctx], i) => { emails[`e${i + 2}`] = { address, contexts: { [ctx]: true } }; });
return {
id,
addressBookIds: { [book]: true },
kind: 'individual',
name: { components: [{ kind: 'given', value: p.given }, { kind: 'surname', value: p.surname }] },
emails,
...(photo ? { media: { photo1: { kind: 'photo', uri: photo, mediaType: 'image/jpeg' } } } : {}),
...rest,
};
}
const meridian = (unit: string, title: string) => ({
organizations: { o1: { name: 'Meridian Labs', units: [{ name: unit }] } },
titles: { t1: { name: title, kind: 'title' as const } },
});
const phone = (number: string, feature: 'voice' | 'cell' = 'cell', context: 'work' | 'private' = 'work') =>
({ p1: { number, features: { [feature]: true }, contexts: { [context]: true } } });
export function createDemoContacts(): ContactCard[] {
return [
// ── Personal address book ──────────────────────────────────
{
id: 'demo-contact-1',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Johnson' }] },
emails: { e1: { address: 'alice.johnson@example.com', contexts: { work: true }, pref: 1 } },
phones: { p1: { number: '+1-555-0101', features: { voice: true }, contexts: { work: true } } },
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'Senior Engineer', kind: 'title' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 3, day: 15 } } },
media: photo('women', 44),
},
{
id: 'demo-contact-2',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Chen' }] },
emails: {
e1: { address: 'bob.chen@example.com', contexts: { work: true }, pref: 1 },
e2: { address: 'bob.personal@email.example', contexts: { private: true } },
},
phones: {
p1: { number: '+1-555-0102', features: { voice: true }, contexts: { work: true } },
p2: { number: '+1-555-0103', features: { cell: true }, contexts: { private: true } },
},
organizations: { o1: { name: 'Acme Corp', units: [{ name: 'Backend Team' }] } },
titles: { t1: { name: 'Staff Engineer', kind: 'title' } },
media: photo('men', 32),
},
{
id: 'demo-contact-3',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sarah' }, { kind: 'surname', value: 'Kim' }] },
emails: { e1: { address: 'sarah.kim@example.com', pref: 1 } },
phones: { p1: { number: '+1-555-0104', features: { voice: true } } },
organizations: { o1: { name: 'DesignCo' } },
// ── Meridian Labs ──────────────────────────────────────────
// Nora's own card: it gives her avatar a photo and her @mention a hover card.
person('demo-contact-nora', AB.work, NORA, 'work', {
...meridian('Engineering', 'Engineering Lead'),
moreEmails: [['nora@lindqvist.example', 'private']],
phones: phone('+49 30 555 0199', 'voice'),
anniversaries: { a1: { kind: 'birth', date: { year: 1990, month: 6, day: 2 } } },
}),
person('demo-contact-alice', AB.work, ALICE, 'work', {
...meridian('Engineering', 'Engineering Manager'),
phones: phone('+49 30 555 0101', 'voice'),
anniversaries: { a1: { kind: 'birth', date: { year: 1988, month: 3, day: 15 } } },
notes: { n1: { note: 'My manager. Prefers written proposals to meetings.' } },
}),
person('demo-contact-bob', AB.work, BOB, 'work', {
...meridian('Platform', 'Staff Engineer'),
moreEmails: [['bob.personal@email.example', 'private']],
phones: { p1: { number: '+49 30 555 0102', features: { voice: true }, contexts: { work: true } }, p2: { number: '+49 171 555 0103', features: { cell: true }, contexts: { private: true } } },
}),
person('demo-contact-priya', AB.work, PRIYA, 'work', { ...meridian('Quality', 'QA Lead'), phones: phone('+49 30 555 0104', 'voice') }),
person('demo-contact-ahmed', AB.work, AHMED, 'work', { ...meridian('Platform', 'Site Reliability Engineer'), phones: phone('+49 171 555 0105') }),
person('demo-contact-james', AB.work, JAMES, 'work', { ...meridian('Leadership', 'CTO'), phones: phone('+49 30 555 0100', 'voice') }),
person('demo-contact-michael', AB.work, MICHAEL, 'work', { ...meridian('Product', 'Product Manager, Beacon'), phones: phone('+49 171 555 0106') }),
person('demo-contact-rachel', AB.work, RACHEL, 'work', { ...meridian('Marketing', 'Marketing Lead') }),
person('demo-contact-wei', AB.work, WEI, 'work', { ...meridian('Data', 'Data Scientist') }),
person('demo-contact-maria', AB.work, MARIA, 'work', { ...meridian('People Ops', 'People Partner'), phones: phone('+49 30 555 0108', 'voice') }),
person('demo-contact-lisa', AB.work, LISA, 'work', { ...meridian('Engineering', 'Frontend Engineer') }),
// ── Work, outside the company ──────────────────────────────
person('demo-contact-sarah', AB.work, SARAH, 'work', {
organizations: { o1: { name: 'DesignCo Studio' } },
titles: { t1: { name: 'UX Designer', kind: 'title' } },
media: photo('women', 68),
},
{
id: 'demo-contact-4',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Carlos' }, { kind: 'surname', value: 'Rivera' }] },
emails: { e1: { address: 'carlos.rivera@example.com', pref: 1 } },
phones: { p1: { number: '+1-555-0105', features: { cell: true } } },
notes: { n1: { note: 'Met at the DevConf 2024 conference' } },
media: photo('men', 15),
},
{
id: 'demo-contact-5',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Emma' }, { kind: 'surname', value: 'Wilson' }] },
emails: { e1: { address: 'emma.wilson@example.com', pref: 1 } },
addresses: {
a1: {
components: [
{ kind: 'number', value: '456' },
{ kind: 'name', value: 'Elm Street' },
{ kind: 'locality', value: 'Springfield' },
{ kind: 'region', value: 'IL' },
{ kind: 'postcode', value: '62701' },
],
contexts: { private: true },
},
},
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
media: photo('women', 22),
},
{
id: 'demo-contact-6',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'David' }, { kind: 'surname', value: 'Park' }] },
emails: { e1: { address: 'david.park@example.com', pref: 1 } },
phones: { p1: { number: '+82-10-1234-5678', features: { cell: true } } },
media: photo('men', 67),
},
{
id: 'demo-contact-7',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'org',
name: { components: [{ kind: 'surname', value: 'Local Coffee Shop' }] },
emails: { e1: { address: 'hello@localcoffee.example', pref: 1 } },
phones: { p1: { number: '+1-555-0200', features: { voice: true } } },
addresses: {
a1: {
components: [
{ kind: 'number', value: '789' },
{ kind: 'name', value: 'Main Street' },
{ kind: 'locality', value: 'Anytown' },
{ kind: 'region', value: 'CA' },
{ kind: 'postcode', value: '90210' },
],
},
},
},
{
id: 'demo-contact-8',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Lisa' }, { kind: 'surname', value: 'Tanaka' }] },
emails: { e1: { address: 'lisa.tanaka@example.com', pref: 1 } },
media: photo('women', 85),
},
{
id: 'demo-contact-16',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Sofia' }, { kind: 'surname', value: 'Russo' }] },
emails: { e1: { address: 'sofia.russo@example.com', contexts: { private: true }, pref: 1 } },
phones: { p1: { number: '+39-340-555-0111', features: { cell: true }, contexts: { private: true } } },
notes: { n1: { note: 'Mom' } },
anniversaries: { a1: { kind: 'birth', date: { year: 1962, month: 5, day: 9 } } },
media: photo('women', 3),
},
{
id: 'demo-contact-17',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Kowalski' }] },
emails: { e1: { address: 'anna.kowalski@example.com', contexts: { private: true }, pref: 1 } },
phones: { p1: { number: '+48-602-555-0144', features: { cell: true } } },
notes: { n1: { note: 'Sister - lives in Kraków' } },
anniversaries: { a1: { kind: 'birth', date: { month: 11, day: 4 } } },
media: photo('women', 47),
},
{
id: 'demo-contact-18',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Marcus' }, { kind: 'surname', value: 'Hughes' }] },
emails: { e1: { address: 'marcus.hughes@example.com', pref: 1 } },
notes: { n1: { note: 'College friend - book club organiser' } },
media: photo('men', 96),
},
{
id: 'demo-contact-19',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Olivia' }, { kind: 'surname', value: 'Bennett' }] },
emails: { e1: { address: 'olivia.bennett@example.com', contexts: { work: true }, pref: 1 } },
phones: phone('+49 30 555 0204', 'voice'),
addresses: { a1: { components: [{ kind: 'name', value: 'Torstraße' }, { kind: 'number', value: '112' }, { kind: 'postcode', value: '10119' }, { kind: 'locality', value: 'Berlin' }], contexts: { work: true } } },
notes: { n1: { note: 'Website redesign. Invoices monthly, net 14.' } },
}),
person('demo-contact-olivia', AB.work, OLIVIA, 'work', {
organizations: { o1: { name: 'Northwind Studio' } },
titles: { t1: { name: 'Product Designer', kind: 'title' } },
media: photo('women', 91),
}),
person('demo-contact-kenji', AB.work, KENJI, 'work', {
organizations: { o1: { name: 'Infra Summit' } },
titles: { t1: { name: 'Programme chair', kind: 'title' } },
phones: phone('+351 21 555 0170', 'voice'),
}),
person('demo-contact-jennifer', AB.work, JENNIFER, 'work', {
organizations: { o1: { name: 'Talent Partners' } },
titles: { t1: { name: 'Principal Recruiter', kind: 'title' } },
}),
person('demo-contact-tomas', AB.work, TOMAS, 'work', {
organizations: { o1: { name: 'Lumen Logistics', units: [{ name: 'Operations' }] } },
titles: { t1: { name: 'Head of Operations', kind: 'title' } },
phones: phone('+351 21 555 0188', 'voice'),
addresses: { a1: { components: [{ kind: 'name', value: 'Av. da Liberdade' }, { kind: 'number', value: '110' }, { kind: 'postcode', value: '1250-146' }, { kind: 'locality', value: 'Lisboa' }, { kind: 'country', value: 'Portugal' }], contexts: { work: true } } },
notes: { n1: { note: 'Design partner. Wants Lumen on the Beacon canary.' } },
}),
person('demo-contact-lena', AB.work, LENA, 'work', { organizations: { o1: { name: 'Hoffmann & Partner' } }, titles: { t1: { name: 'Partner', kind: 'title' } } }),
person('demo-contact-yusuf', AB.work, YUSUF, 'work', { organizations: { o1: { name: 'Demir Retail' } }, titles: { t1: { name: 'Lead Developer', kind: 'title' } } }),
// ── Personal ───────────────────────────────────────────────
person('demo-contact-sofia', AB.personal, SOFIA, 'private', {
phones: phone('+39 051 555 0111', 'voice', 'private'),
addresses: { a1: { components: [{ kind: 'name', value: 'Via Zamboni' }, { kind: 'number', value: '18' }, { kind: 'postcode', value: '40126' }, { kind: 'locality', value: 'Bologna' }, { kind: 'country', value: 'Italy' }], contexts: { private: true } } },
anniversaries: { a1: { kind: 'birth', date: { year: 1962, month: 5, day: 9 } } },
notes: { n1: { note: 'Mom. Writing a memoir, "The Lemon Tree Year".' } },
relatedTo: { [ANNA.email]: { relation: { child: true } } },
}),
person('demo-contact-anna', AB.personal, ANNA, 'private', {
phones: phone('+48 602 555 0144', 'cell', 'private'),
addresses: { a1: { components: [{ kind: 'name', value: 'ul. Floriańska' }, { kind: 'number', value: '22' }, { kind: 'postcode', value: '31-021' }, { kind: 'locality', value: 'Kraków' }, { kind: 'country', value: 'Poland' }], contexts: { private: true } } },
anniversaries: { a1: { kind: 'birth', date: { year: 1992, month: 11, day: 4 } } },
notes: { n1: { note: 'Sister.' } },
}),
person('demo-contact-elias', AB.personal, ELIAS, 'private', {
phones: phone('+46 70 555 0122', 'cell', 'private'),
addresses: { a1: { components: [{ kind: 'name', value: 'Linnégatan' }, { kind: 'number', value: '41' }, { kind: 'postcode', value: '413 08' }, { kind: 'locality', value: 'Göteborg' }, { kind: 'country', value: 'Sweden' }], contexts: { private: true } } },
anniversaries: { a1: { kind: 'birth', date: { year: 1986, month: 8, day: 23 } } },
notes: { n1: { note: 'Brother.' } },
}),
person('demo-contact-carlos', AB.personal, CARLOS, 'private', {
phones: phone('+49 176 555 0105', 'cell', 'private'),
anniversaries: { a1: { kind: 'birth', date: { month: 10, day: 15 } } },
notes: { n1: { note: 'Met at DevConf 2024. Organises the Friday dinners.' } },
}),
person('demo-contact-marcus', AB.personal, MARCUS, 'private', { notes: { n1: { note: 'Book club host, every other week.' } } }),
person('demo-contact-emma', AB.personal, EMMA, 'private', {
addresses: { a1: { components: [{ kind: 'name', value: 'Schönhauser Allee' }, { kind: 'number', value: '56' }, { kind: 'postcode', value: '10437' }, { kind: 'locality', value: 'Berlin' }], contexts: { private: true } } },
anniversaries: { a1: { kind: 'birth', date: { month: 7, day: 22 } } },
}),
person('demo-contact-david', AB.personal, DAVID, 'private', { phones: phone('+82 10 1234 5678', 'cell', 'private') }),
person('demo-contact-daniel', AB.personal, DANIEL, 'private', {
organizations: { o1: { name: 'Cooper Creative' } },
titles: { t1: { name: 'Illustrator', kind: 'title' } },
notes: { n1: { note: 'Doing the cover for Mom\'s book.' } },
}),
{
id: 'demo-contact-dentist',
addressBookIds: { [AB.personal]: true },
kind: 'org',
name: { components: [{ kind: 'surname', value: 'Zahnarztpraxis Dr. Weber' }] },
emails: { e1: { address: 'termine@dr-weber.example', pref: 1 } },
phones: { p1: { number: '+49 30 555 0142', features: { voice: true } } },
addresses: { a1: { components: [{ kind: 'name', value: 'Kastanienallee' }, { kind: 'number', value: '21' }, { kind: 'postcode', value: '10435' }, { kind: 'locality', value: 'Berlin' }] } },
},
{
id: 'demo-contact-20',
addressBookIds: { 'demo-addressbook-personal': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Daniel' }, { kind: 'surname', value: 'Cooper' }] },
emails: { e1: { address: 'daniel.cooper@example.com', pref: 1 } },
organizations: { o1: { name: 'Freelance' } },
titles: { t1: { name: 'Illustrator', kind: 'title' } },
media: photo('men', 76),
id: 'demo-contact-cafe',
addressBookIds: { [AB.personal]: true },
kind: 'org',
name: { components: [{ kind: 'surname', value: 'Café Kranzler' }] },
emails: { e1: { address: 'hello@kranzler.example', pref: 1 } },
phones: { p1: { number: '+49 30 555 0200', features: { voice: true } } },
addresses: { a1: { components: [{ kind: 'name', value: 'Rosenthaler Straße' }, { kind: 'number', value: '38' }, { kind: 'postcode', value: '10178' }, { kind: 'locality', value: 'Berlin' }] } },
notes: { n1: { note: 'Coffee downstairs from the office.' } },
},
// ── Work address book ──────────────────────────────────────
// ── Groups ─────────────────────────────────────────────────
{
id: 'demo-contact-9',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Michael' }, { kind: 'surname', value: 'Torres' }] },
emails: { e1: { address: 'michael.torres@company.example', contexts: { work: true }, pref: 1 } },
phones: { p1: { number: '+1-555-0301', features: { voice: true }, contexts: { work: true } } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Product' }] } },
titles: { t1: { name: 'Product Manager', kind: 'title' } },
media: photo('men', 41),
id: 'demo-group-beacon',
addressBookIds: { [AB.work]: true },
kind: 'group',
name: { components: [{ kind: 'surname', value: 'Beacon team' }] },
members: { 'demo-contact-alice': true, 'demo-contact-bob': true, 'demo-contact-priya': true, 'demo-contact-ahmed': true, 'demo-contact-lisa': true },
},
{
id: 'demo-contact-10',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Rachel' }, { kind: 'surname', value: 'Green' }] },
emails: { e1: { address: 'rachel.green@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Marketing' }] } },
titles: { t1: { name: 'Marketing Lead', kind: 'title' } },
media: photo('women', 12),
id: 'demo-group-leadership',
addressBookIds: { [AB.work]: true },
kind: 'group',
name: { components: [{ kind: 'surname', value: 'Leadership' }] },
members: { 'demo-contact-james': true, 'demo-contact-michael': true, 'demo-contact-alice': true },
},
{
id: 'demo-contact-11',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'James' }, { kind: 'surname', value: 'Miller' }] },
emails: { e1: { address: 'james.miller@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Engineering' }] } },
titles: { t1: { name: 'CTO', kind: 'title' } },
media: photo('men', 52),
id: 'demo-group-family',
addressBookIds: { [AB.personal]: true },
kind: 'group',
name: { components: [{ kind: 'surname', value: 'Family' }] },
members: { 'demo-contact-sofia': true, 'demo-contact-anna': true, 'demo-contact-elias': true },
},
{
id: 'demo-contact-12',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Priya' }, { kind: 'surname', value: 'Sharma' }] },
emails: { e1: { address: 'priya.sharma@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'QA' }] } },
titles: { t1: { name: 'QA Engineer', kind: 'title' } },
media: photo('women', 77),
},
{
id: 'demo-contact-13',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Ahmed' }, { kind: 'surname', value: 'Hassan' }] },
emails: { e1: { address: 'ahmed.hassan@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'DevOps' }] } },
titles: { t1: { name: 'DevOps Engineer', kind: 'title' } },
media: photo('men', 89),
},
{
id: 'demo-contact-14',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Maria' }, { kind: 'surname', value: 'Lopez' }] },
emails: { e1: { address: 'maria.lopez@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'HR' }] } },
titles: { t1: { name: 'HR Business Partner', kind: 'title' } },
media: photo('women', 55),
},
{
id: 'demo-contact-15',
addressBookIds: { 'demo-addressbook-work': true },
kind: 'individual',
name: { components: [{ kind: 'given', value: 'Wei' }, { kind: 'surname', value: 'Zhang' }] },
emails: { e1: { address: 'wei.zhang@company.example', contexts: { work: true }, pref: 1 } },
organizations: { o1: { name: 'Company Inc', units: [{ name: 'Data Science' }] } },
titles: { t1: { name: 'Data Scientist', kind: 'title' } },
media: photo('men', 8),
id: 'demo-group-bookclub',
addressBookIds: { [AB.personal]: true },
kind: 'group',
name: { components: [{ kind: 'surname', value: 'Book club' }] },
members: { 'demo-contact-marcus': true, 'demo-contact-emma': true, 'demo-contact-david': true },
},
];
}
/** The persona as a contact card, for the hover card on her own @mention. */
export const SELF_CONTACT_EMAIL = NORA.email;
File diff suppressed because it is too large Load Diff
+47 -135
View File
@@ -1,146 +1,58 @@
import type { FileNode } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
import { DEMO_ASSETS, type DemoBlobId } from './assets';
const FOLDER = {
documents: 'demo-file-documents',
beacon: 'demo-file-beacon',
photos: 'demo-file-photos',
travel: 'demo-file-travel',
} as const;
function folder(id: string, name: string, parentId: string | null, createdDays: number, modifiedDays: number): FileNode {
return { id, parentId, name, type: 'd', blobId: null, size: 0, created: demoDate(-createdDays), modified: demoDate(-modifiedDays) };
}
/** A file backed by one of the demo assets; name and size come from the asset. */
function file(id: string, parentId: string | null, blobId: DemoBlobId, createdDays: number, modifiedDays = createdDays, name?: string): FileNode {
const asset = DEMO_ASSETS[blobId];
return { id, parentId, name: name ?? asset.name, type: asset.type, blobId, size: asset.size, created: demoDate(-createdDays), modified: demoDate(-modifiedDays) };
}
export function createDemoFileNodes(): FileNode[] {
return [
// Root-level directories
{
id: 'demo-file-documents',
parentId: null,
name: 'Documents',
type: 'd',
blobId: null,
size: 0,
created: demoDate(-30),
modified: demoDate(-2),
},
{
id: 'demo-file-photos',
parentId: null,
name: 'Photos',
type: 'd',
blobId: null,
size: 0,
created: demoDate(-30),
modified: demoDate(-5),
},
folder(FOLDER.documents, 'Documents', null, 400, 1),
folder(FOLDER.beacon, 'Beacon', FOLDER.documents, 60, 1),
folder(FOLDER.photos, 'Photos', null, 400, 5),
folder(FOLDER.travel, 'Travel', null, 24, 2),
// Documents contents
{
id: 'demo-file-meeting-notes',
parentId: 'demo-file-documents',
name: 'meeting-notes.md',
type: 'text/markdown',
blobId: 'demo-blob-file-1',
size: 2150,
created: demoDate(-7),
modified: demoDate(-2),
},
{
id: 'demo-file-quarterly-report',
parentId: 'demo-file-documents',
name: 'quarterly-report.pdf',
type: 'application/pdf',
blobId: 'demo-blob-file-2',
size: 148480,
created: demoDate(-14),
modified: demoDate(-14),
},
{
id: 'demo-file-todo',
parentId: 'demo-file-documents',
name: 'todo.txt',
type: 'text/plain',
blobId: 'demo-blob-file-3',
size: 410,
created: demoDate(-3),
modified: demoDate(-1),
},
// Documents
file('demo-file-meeting-notes', FOLDER.documents, 'demo-blob-meeting-notes', 7, 1),
file('demo-file-todo', FOLDER.documents, 'demo-blob-todo', 3, 0),
file('demo-file-quarterly-report', FOLDER.documents, 'demo-blob-quarterly-report', 3, 2),
file('demo-file-service-agreement', FOLDER.documents, 'demo-blob-service-agreement', 9, 1),
file('demo-file-budget', FOLDER.documents, 'demo-blob-budget', 40, 4),
// Office documents (openable in a WOPI editor when one is configured, #425)
{
id: 'demo-file-project-proposal',
parentId: 'demo-file-documents',
name: 'project-proposal.odt',
type: 'application/vnd.oasis.opendocument.text',
blobId: 'demo-blob-file-7',
size: 24576,
created: demoDate(-12),
modified: demoDate(-4),
},
{
id: 'demo-file-contract-draft',
parentId: 'demo-file-documents',
name: 'contract-draft.docx',
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
blobId: 'demo-blob-file-8',
size: 38912,
created: demoDate(-9),
modified: demoDate(-2),
},
{
id: 'demo-file-team-timesheet',
parentId: 'demo-file-documents',
name: 'team-timesheet.ods',
type: 'application/vnd.oasis.opendocument.spreadsheet',
blobId: 'demo-blob-file-9',
size: 17408,
created: demoDate(-6),
modified: demoDate(-1),
},
{
id: 'demo-file-roadmap-presentation',
parentId: 'demo-file-documents',
name: 'roadmap-presentation.odp',
type: 'application/vnd.oasis.opendocument.presentation',
blobId: 'demo-blob-file-10',
size: 245760,
created: demoDate(-16),
modified: demoDate(-8),
},
// Documents / Beacon
file('demo-file-launch-plan', FOLDER.beacon, 'demo-blob-launch-plan', 2),
file('demo-file-runbook', FOLDER.beacon, 'demo-blob-runbook', 1, 1),
file('demo-file-soak-metrics', FOLDER.beacon, 'demo-blob-soak-metrics', 1),
file('demo-file-soak-dashboard', FOLDER.beacon, 'demo-blob-soak-dashboard', 1),
file('demo-file-launch-deck', FOLDER.beacon, 'demo-blob-launch-deck', 5, 2),
// Photos contents
{
id: 'demo-file-vacation',
parentId: 'demo-file-photos',
name: 'vacation.jpg',
type: 'image/jpeg',
blobId: 'demo-blob-file-4',
size: 91949,
created: demoDate(-10),
modified: demoDate(-10),
},
{
id: 'demo-file-team-photo',
parentId: 'demo-file-photos',
name: 'team-photo.jpg',
type: 'image/jpeg',
blobId: 'demo-blob-file-5',
size: 71066,
created: demoDate(-21),
modified: demoDate(-21),
},
// Photos
file('demo-file-team-photo', FOLDER.photos, 'demo-blob-team-photo', 120),
file('demo-file-lake', FOLDER.photos, 'demo-blob-vacation', 95),
file('demo-file-wedding-1', FOLDER.photos, 'demo-blob-wedding-1', 1),
file('demo-file-wedding-2', FOLDER.photos, 'demo-blob-wedding-2', 1),
file('demo-file-wedding-3', FOLDER.photos, 'demo-blob-wedding-3', 1),
// Root-level files
{
id: 'demo-file-budget',
parentId: null,
name: 'budget.xlsx',
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
blobId: 'demo-blob-file-6',
size: 68608,
created: demoDate(-5),
modified: demoDate(-1),
},
{
id: 'demo-file-launch-deck',
parentId: null,
name: 'product-launch.pptx',
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
blobId: 'demo-blob-file-11',
size: 512000,
created: demoDate(-4),
modified: demoDate(-3),
},
// Travel
file('demo-file-eticket', FOLDER.travel, 'demo-blob-boarding-pass', 3, 3, 'E-ticket-E7KQ2R.pdf'),
file('demo-file-hotel', FOLDER.travel, 'demo-blob-hotel-voucher', 3),
file('demo-file-summit-ticket', FOLDER.travel, 'demo-blob-summit-ticket', 2),
// Root
file('demo-file-prototype', null, 'demo-blob-prototype', 1),
];
}
+27 -13
View File
@@ -4,7 +4,7 @@ export function createDemoSieveCapabilities(): SieveCapabilities {
return {
implementation: 'Demo Sieve Engine',
maxSizeScript: 65536,
sieveExtensions: ['fileinto', 'reject', 'vacation', 'imap4flags', 'comparator-i;ascii-casemap', 'body', 'envelope'],
sieveExtensions: ['fileinto', 'reject', 'vacation', 'imap4flags', 'comparator-i;ascii-casemap', 'body', 'envelope', 'mailbox', 'variables'],
notificationMethods: [],
externalLists: [],
};
@@ -14,34 +14,48 @@ export function createDemoSieveScripts(): SieveScript[] {
return [
{
id: 'demo-sieve-1',
name: 'Default Filters',
name: 'Inbox rules',
blobId: 'demo-sieve-blob-1',
isActive: true,
},
];
}
// Sieve script content keyed by blobId
// Sieve script content keyed by blobId. The rules match the folders and the
// mail in the fixtures, so the filter editor shows something coherent.
export function createDemoSieveContent(): Record<string, string> {
return {
'demo-sieve-blob-1': [
'require ["fileinto", "imap4flags"];',
'require ["fileinto", "imap4flags", "mailbox"];',
'',
'# Newsletters to Receipts',
'if address :contains "from" "newsletter@" {',
' fileinto "Receipts";',
'# Newsletters out of the inbox',
'if exists "List-Unsubscribe" {',
' if address :domain :is "from" ["github.com", "linear.app"] { keep; stop; }',
' fileinto "Newsletters";',
' stop;',
'}',
'',
'# Flag emails from boss',
'if address :is "from" "alice.johnson@example.com" {',
'# Receipts and invoices',
'if anyof (address :is "from" ["billing@hetzner.com", "receipts@stripe.com", "billing@github.com", "support@porkbun.com"],',
' header :contains "subject" ["receipt", "invoice"]) {',
' fileinto :create "Receipts";',
' stop;',
'}',
'',
'# Travel confirmations',
'if address :domain :is "from" ["lufthansa.com", "booking.com", "bahn.de"] {',
' fileinto "Travel";',
' stop;',
'}',
'',
'# Flag anything from Alice',
'if address :is "from" "alice.johnson@meridianlabs.example" {',
' addflag "\\\\Flagged";',
'}',
'',
'# Move project updates',
'if header :contains "subject" "[Project]" {',
' fileinto "Projects";',
' stop;',
'# Beacon mail gets its label',
'if header :contains "subject" ["Beacon", "BEA-"] {',
' addflag "$label:beacon";',
'}',
].join('\n'),
};
+18 -8
View File
@@ -1,21 +1,31 @@
import type { Identity } from '@/lib/jmap/types';
import { NORA, NORA_ALIAS, SUPPORT } from './people';
export function createDemoIdentities(): Identity[] {
return [
{
id: 'demo-identity-primary',
name: 'Demo User',
email: 'demo@example.com',
textSignature: 'Best regards,\nDemo User\nBulwark Mail Demo',
htmlSignature: '<p>Best regards,<br><b>Demo User</b><br>Bulwark Mail Demo</p>',
name: NORA.name,
email: NORA.email,
textSignature: 'Nora Lindqvist\nEngineering Lead, Meridian Labs\n+49 30 555 0199',
htmlSignature: '<p><b>Nora Lindqvist</b><br>Engineering Lead, Meridian Labs<br>+49 30 555 0199</p>',
mayDelete: false,
},
{
id: 'demo-identity-alias',
name: 'Demo User',
email: 'demo+newsletter@example.com',
textSignature: '',
htmlSignature: '',
name: NORA_ALIAS.name,
email: NORA_ALIAS.email,
textSignature: 'Nora Lindqvist\nMeridian Labs',
htmlSignature: '<p>Nora Lindqvist<br>Meridian Labs</p>',
mayDelete: true,
},
{
id: 'demo-identity-support',
name: 'Meridian Support',
email: SUPPORT.email,
replyTo: [{ name: 'Meridian Support', email: SUPPORT.email }],
textSignature: 'Nora, for Meridian Support\nsupport@meridianlabs.example',
htmlSignature: '<p>Nora, for Meridian Support<br>support@meridianlabs.example</p>',
mayDelete: true,
},
];
+15
View File
@@ -0,0 +1,15 @@
import type { KeywordDefinition } from '@/stores/settings-store';
/**
* The persona's tags. The mail fixtures set these as `$label:<id>` keywords;
* demo login seeds the definitions into the settings store so the sidebar
* shows them with names and colours instead of the built-in colour names.
*/
export const DEMO_KEYWORDS: KeywordDefinition[] = [
{ id: 'beacon', label: 'Beacon', color: 'purple' },
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'family', label: 'Family', color: 'pink' },
{ id: 'travel', label: 'Travel', color: 'orange' },
{ id: 'finance', label: 'Finance', color: 'green' },
{ id: 'follow-up', label: 'Follow-up', color: 'red' },
];
+159
View File
@@ -0,0 +1,159 @@
/**
* Helpers that keep the mail fixtures short: one `mail()` call per message,
* with the body parts, preview, size and message id derived from a few
* fields. Headers are written the way Stalwart writes them, so the viewer's
* banners (sender authentication, unsubscribe, read receipt) have the same
* input as on a real server.
*/
import type { Email, EmailAddress, Attachment } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
import { NORA, WORK_DOMAIN } from './people';
export const MX = `mx.${WORK_DOMAIN}`;
// ── Time ─────────────────────────────────────────────────────
/** Hours and minutes before now, for today's mail. */
export const ago = (hours: number, minutes = 0): string => demoDate(0, -hours, -minutes);
/** A wall-clock time on a day in the past (1 = yesterday) or future (negative). */
export function at(daysAgo: number, hour: number, minute = 0): string {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
d.setHours(hour, minute, 0, 0);
return d.toISOString();
}
/** Weekday name of a day relative to today, for body text ("see you Thursday"). */
export function dayName(daysAhead: number): string {
const d = new Date();
d.setDate(d.getDate() + daysAhead);
return d.toLocaleDateString('en-US', { weekday: 'long' });
}
/** Short date of a day relative to today ("Oct 21"). */
export function dayLabel(daysAhead: number): string {
const d = new Date();
d.setDate(d.getDate() + daysAhead);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
// ── Body parts ───────────────────────────────────────────────
let partCounter = 0;
function part(value: string, type: 'text/plain' | 'text/html') {
const partId = String(++partCounter);
return {
part: { partId, blobId: `blob-${partId}`, size: value.length, type },
values: { [partId]: { value } },
};
}
function bodyParts(text: string, html?: string): Pick<Email, 'textBody' | 'htmlBody' | 'bodyValues'> {
const t = part(text, 'text/plain');
if (!html) return { textBody: [t.part], bodyValues: t.values };
const h = part(html, 'text/html');
return { textBody: [t.part], htmlBody: [h.part], bodyValues: { ...t.values, ...h.values } };
}
/** Paragraphs separated by blank lines become <p>; single newlines become <br>. */
export function htmlFromText(text: string): string {
const escape = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return text
.trim()
.split(/\n{2,}/)
.map((para) => `<p>${escape(para).replace(/\n/g, '<br>')}</p>`)
.join('');
}
// ── Headers ──────────────────────────────────────────────────
/** The receiving server's results for mail that checks out. */
export function authPass(domain: string, opts: { spam?: number } = {}): Record<string, string> {
return {
'Authentication-Results': `${MX}; dkim=pass header.d=${domain} header.s=mail; spf=pass smtp.mailfrom=${domain}; dmarc=pass header.from=${domain}; iprev=pass`,
'X-Spam-Score': `ham, score=${(opts.spam ?? -1.4).toFixed(2)}`,
};
}
/** A forged sender: SPF and DKIM for the envelope domain, DMARC fails for the From domain. */
export function authFail(fromDomain: string, envelopeDomain: string, opts: { spam?: number; llm?: string } = {}): Record<string, string> {
return {
'Authentication-Results': `${MX}; dkim=fail header.d=${fromDomain}; spf=fail smtp.mailfrom=${envelopeDomain}; dmarc=fail header.from=${fromDomain} policy.dmarc=reject; iprev=fail`,
'X-Spam-Score': `spam, score=${(opts.spam ?? 9.1).toFixed(2)}`,
...(opts.llm ? { 'X-Spam-LLM': opts.llm } : {}),
};
}
/** Mailing-list headers of a newsletter, with a one-click unsubscribe. */
export function listHeaders(listName: string, domain: string): Record<string, string> {
const slug = listName.toLowerCase().replace(/[^a-z0-9]+/g, '-');
return {
'List-Id': `${listName} <${slug}.${domain}>`,
'List-Unsubscribe': `<https://${domain}/unsubscribe?u=4f2a9c>, <mailto:unsubscribe@${domain}?subject=unsubscribe>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
'Precedence': 'bulk',
};
}
// ── The message builder ──────────────────────────────────────
export interface MailSpec {
id: string;
/** Thread id; defaults to the message's own id. */
thread?: string;
/** Mailbox id, or several for a message filed in more than one folder. */
mailbox: string | string[];
from: EmailAddress;
/** Defaults to the persona's work address. */
to?: EmailAddress[];
cc?: EmailAddress[];
replyTo?: EmailAddress[];
subject: string;
/** receivedAt (and sentAt unless given). */
date: string;
sentAt?: string;
text: string;
/** HTML body; `true` renders the text as simple HTML. */
html?: string | true;
attachments?: Attachment[];
/** Keywords that are set, e.g. ['$seen', '$label:work']. */
keywords?: string[];
headers?: Record<string, string | string[]>;
messageId?: string;
inReplyTo?: string[];
references?: string[];
preview?: string;
}
export function mail(spec: MailSpec): Email {
const mailboxIds = Object.fromEntries((Array.isArray(spec.mailbox) ? spec.mailbox : [spec.mailbox]).map((m) => [m, true]));
const keywords = Object.fromEntries((spec.keywords ?? []).map((k) => [k, true]));
const html = spec.html === true ? htmlFromText(spec.text) : spec.html;
const parts = bodyParts(spec.text, html);
const attachmentBytes = (spec.attachments ?? []).reduce((sum, a) => sum + a.size, 0);
const domain = spec.from.email.slice(spec.from.email.indexOf('@') + 1);
const preview = spec.preview ?? spec.text.replace(/\s+/g, ' ').trim().slice(0, 140);
return {
id: spec.id,
threadId: spec.thread ?? spec.id,
mailboxIds,
keywords,
size: 1200 + spec.text.length + (html?.length ?? 0) + Math.round(attachmentBytes * 1.37),
receivedAt: spec.date,
sentAt: spec.sentAt ?? spec.date,
from: [spec.from],
to: spec.to ?? [{ name: NORA.name, email: NORA.email }],
...(spec.cc ? { cc: spec.cc } : {}),
...(spec.replyTo ? { replyTo: spec.replyTo } : {}),
subject: spec.subject,
preview,
hasAttachment: (spec.attachments?.length ?? 0) > 0,
...(spec.attachments ? { attachments: spec.attachments } : {}),
...parts,
messageId: spec.messageId ?? `<${spec.id}@${domain}>`,
...(spec.inReplyTo ? { inReplyTo: spec.inReplyTo } : {}),
...(spec.references ? { references: spec.references } : {}),
...(spec.headers ? { headers: spec.headers } : {}),
};
}
/** Message id of a fixture, for In-Reply-To / References chains. */
export const mid = (id: string, domain = WORK_DOMAIN): string => `<${id}@${domain}>`;
+83 -9
View File
@@ -3,16 +3,90 @@ import type { Mailbox } from '@/lib/jmap/types';
const RIGHTS_SYSTEM = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: false, mayDelete: false, maySubmit: true };
const RIGHTS_CUSTOM = { ...RIGHTS_SYSTEM, mayRename: true, mayDelete: true };
// Counts must stay in sync with createDemoEmails() in fixtures/emails.ts.
// Counts are left at zero here; DemoJMAPClient recomputes them from the mail
// when it starts, so they can never drift from the fixtures.
const COUNTS = { totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 };
const system = (id: string, name: string, role: string, sortOrder: number, parentId?: string): Mailbox =>
({ id, name, role, sortOrder, ...COUNTS, myRights: RIGHTS_SYSTEM, isSubscribed: true, ...(parentId ? { parentId } : {}) });
const custom = (id: string, name: string, sortOrder: number, parentId?: string): Mailbox =>
({ id, name, sortOrder, ...COUNTS, myRights: RIGHTS_CUSTOM, isSubscribed: true, ...(parentId ? { parentId } : {}) });
// ── Work account (the primary login) ─────────────────────────
export const MB = {
inbox: 'demo-mailbox-inbox',
drafts: 'demo-mailbox-drafts',
sent: 'demo-mailbox-sent',
archive: 'demo-mailbox-archive',
archive2025: 'demo-mailbox-archive-2025',
archive2024: 'demo-mailbox-archive-2024',
junk: 'demo-mailbox-junk',
trash: 'demo-mailbox-trash',
projects: 'demo-mailbox-projects',
beacon: 'demo-mailbox-projects-beacon',
website: 'demo-mailbox-projects-website',
receipts: 'demo-mailbox-receipts',
travel: 'demo-mailbox-travel',
newsletters: 'demo-mailbox-newsletters',
} as const;
export function createDemoMailboxes(): Mailbox[] {
return [
{ id: 'demo-mailbox-inbox', name: 'Inbox', role: 'inbox', sortOrder: 1, totalEmails: 22, unreadEmails: 13, totalThreads: 20, unreadThreads: 12, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-sent', name: 'Sent', role: 'sent', sortOrder: 2, totalEmails: 3, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-drafts', name: 'Drafts', role: 'drafts', sortOrder: 3, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-trash', name: 'Trash', role: 'trash', sortOrder: 5, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-archive', name: 'Archive', role: 'archive', sortOrder: 4, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-junk', name: 'Spam', role: 'junk', sortOrder: 6, totalEmails: 3, unreadEmails: 3, totalThreads: 3, unreadThreads: 3, myRights: RIGHTS_SYSTEM, isSubscribed: true },
{ id: 'demo-mailbox-projects', name: 'Projects', sortOrder: 10, totalEmails: 3, unreadEmails: 2, totalThreads: 3, unreadThreads: 2, myRights: RIGHTS_CUSTOM, isSubscribed: true },
{ id: 'demo-mailbox-receipts', name: 'Receipts', sortOrder: 11, totalEmails: 2, unreadEmails: 0, totalThreads: 2, unreadThreads: 0, myRights: RIGHTS_CUSTOM, isSubscribed: true },
system(MB.inbox, 'Inbox', 'inbox', 1),
system(MB.drafts, 'Drafts', 'drafts', 2),
system(MB.sent, 'Sent', 'sent', 3),
system(MB.archive, 'Archive', 'archive', 4),
custom(MB.archive2025, '2025', 1, MB.archive),
custom(MB.archive2024, '2024', 2, MB.archive),
system(MB.junk, 'Spam', 'junk', 5),
system(MB.trash, 'Trash', 'trash', 6),
custom(MB.projects, 'Projects', 10),
custom(MB.beacon, 'Beacon', 1, MB.projects),
custom(MB.website, 'Website redesign', 2, MB.projects),
custom(MB.receipts, 'Receipts', 11),
custom(MB.travel, 'Travel', 12),
custom(MB.newsletters, 'Newsletters', 13),
];
}
// ── Shared Support mailbox (delegated to the work login) ─────
// Bare ids as the owner account holds them; the client namespaces them
// `demo-support:<id>` when it lists them next to the user's own folders.
export const SUPPORT_ACCOUNT_ID = 'demo-support';
export const SUPPORT_ACCOUNT_NAME = 'Support';
export const SMB = {
inbox: 'demo-support-inbox',
sent: 'demo-support-sent',
resolved: 'demo-support-resolved',
} as const;
export function createDemoSupportMailboxes(): Mailbox[] {
return [
system(SMB.inbox, 'Inbox', 'inbox', 1),
system(SMB.sent, 'Sent', 'sent', 2),
custom(SMB.resolved, 'Resolved', 10),
];
}
// ── Personal account (the second login) ──────────────────────
export const PMB = {
inbox: 'demo-personal-inbox',
drafts: 'demo-personal-drafts',
sent: 'demo-personal-sent',
archive: 'demo-personal-archive',
junk: 'demo-personal-junk',
trash: 'demo-personal-trash',
family: 'demo-personal-family',
} as const;
export function createDemoPersonalMailboxes(): Mailbox[] {
return [
system(PMB.inbox, 'Inbox', 'inbox', 1),
system(PMB.drafts, 'Drafts', 'drafts', 2),
system(PMB.sent, 'Sent', 'sent', 3),
system(PMB.archive, 'Archive', 'archive', 4),
system(PMB.junk, 'Spam', 'junk', 5),
system(PMB.trash, 'Trash', 'trash', 6),
custom(PMB.family, 'Family', 10),
];
}
+80
View File
@@ -0,0 +1,80 @@
/**
* The cast of the demo. One place for every name, address and portrait so
* the mail, the address books, the calendar and the shared mailbox all agree.
*
* The persona is Nora Lindqvist, engineering lead at Meridian Labs in Berlin.
* She has three mail sources: her work account (the primary login), a
* personal account on her own domain (a second login) and the company's
* shared Support mailbox (delegated to her, no login of its own).
*
* Portraits are Unsplash photos (free to use under the Unsplash License),
* resized to 256px and served from public/demo/portraits/.
*/
import type { EmailAddress } from '@/lib/jmap/types';
export interface Person extends EmailAddress {
name: string;
email: string;
/** File under public/demo/portraits/, without extension. */
portrait?: string;
given: string;
surname: string;
}
const p = (given: string, surname: string, email: string, portrait?: string): Person =>
({ name: `${given} ${surname}`, email, given, surname, portrait });
export const WORK_DOMAIN = 'meridianlabs.example';
export const PERSONAL_DOMAIN = 'lindqvist.example';
// ── The persona ───────────────────────────────────────────────
export const NORA = p('Nora', 'Lindqvist', `nora@${WORK_DOMAIN}`, 'nora-lindqvist');
export const NORA_ALIAS = p('Nora', 'Lindqvist', `nora.lindqvist@${WORK_DOMAIN}`, 'nora-lindqvist');
export const NORA_PERSONAL = p('Nora', 'Lindqvist', `nora@${PERSONAL_DOMAIN}`, 'nora-lindqvist');
export const SUPPORT: Person = { name: 'Meridian Support', email: `support@${WORK_DOMAIN}`, given: 'Meridian', surname: 'Support' };
// ── Meridian Labs ─────────────────────────────────────────────
export const ALICE = p('Alice', 'Johnson', `alice.johnson@${WORK_DOMAIN}`, 'alice-johnson');
export const BOB = p('Bob', 'Chen', `bob.chen@${WORK_DOMAIN}`, 'bob-chen');
export const PRIYA = p('Priya', 'Sharma', `priya.sharma@${WORK_DOMAIN}`, 'priya-sharma');
export const AHMED = p('Ahmed', 'Hassan', `ahmed.hassan@${WORK_DOMAIN}`, 'ahmed-hassan');
export const JAMES = p('James', 'Miller', `james.miller@${WORK_DOMAIN}`, 'james-miller');
export const MICHAEL = p('Michael', 'Torres', `michael.torres@${WORK_DOMAIN}`, 'michael-torres');
export const RACHEL = p('Rachel', 'Green', `rachel.green@${WORK_DOMAIN}`, 'rachel-green');
export const WEI = p('Wei', 'Zhang', `wei.zhang@${WORK_DOMAIN}`, 'wei-zhang');
export const MARIA = p('Maria', 'Lopez', `maria.lopez@${WORK_DOMAIN}`, 'maria-lopez');
export const LISA = p('Lisa', 'Tanaka', `lisa.tanaka@${WORK_DOMAIN}`, 'lisa-tanaka');
// ── Work, outside the company ─────────────────────────────────
export const SARAH = p('Sarah', 'Kim', 'sarah@designco.example', 'sarah-kim');
export const OLIVIA = p('Olivia', 'Bennett', 'olivia@northwind.example', 'olivia-bennett');
export const JENNIFER = p('Jennifer', 'Hayes', 'jennifer@talent-partners.example', 'jennifer-hayes');
export const KENJI = p('Kenji', 'Watanabe', 'kenji@infrasummit.example', 'kenji-watanabe');
// ── Family and friends ────────────────────────────────────────
export const SOFIA = p('Sofia', 'Russo', 'sofia.russo@example.com', 'sofia-russo');
export const ANNA = p('Anna', 'Kowalski', 'anna.kowalski@example.com', 'anna-kowalski');
export const ELIAS = p('Elias', 'Lindqvist', `elias@${PERSONAL_DOMAIN}`, 'elias-lindqvist');
export const CARLOS = p('Carlos', 'Rivera', 'carlos.rivera@example.com', 'carlos-rivera');
export const MARCUS = p('Marcus', 'Hughes', 'marcus.hughes@example.com', 'marcus-hughes');
export const EMMA = p('Emma', 'Wilson', 'emma.wilson@example.com', 'emma-wilson');
export const DAVID = p('David', 'Park', 'david.park@example.com', 'david-park');
export const DANIEL = p('Daniel', 'Cooper', 'daniel@coopercreative.example', 'daniel-cooper');
// ── Customers writing to Support ──────────────────────────────
export const TOMAS = p('Tomás', 'Ferreira', 'tomas.ferreira@lumenlogistics.example', 'tomas-ferreira');
export const LENA = p('Lena', 'Hoffmann', 'l.hoffmann@hoffmann-partner.example', 'lena-hoffmann');
export const YUSUF = p('Yusuf', 'Demir', 'yusuf@demir-retail.example', 'yusuf-demir');
/** Everyone with a portrait, for the fixture test that checks the files exist. */
export const PORTRAITS: Person[] = [
NORA, ALICE, BOB, PRIYA, AHMED, JAMES, MICHAEL, RACHEL, WEI, MARIA, LISA,
SARAH, OLIVIA, JENNIFER, KENJI, SOFIA, ANNA, ELIAS, CARLOS, MARCUS, EMMA, DAVID, DANIEL,
TOMAS, LENA, YUSUF,
];
export const portraitUri = (person: Person): string | undefined =>
person.portrait ? `/demo/portraits/${person.portrait}.jpg` : undefined;
/** Plain address, without the fixture-only fields, for From/To lists. */
export const addr = (person: Person): EmailAddress => ({ name: person.name, email: person.email });
+173
View File
@@ -0,0 +1,173 @@
/**
* Nora's personal account (nora@lindqvist.example): family, friends, the
* dentist, a parcel. It is a second login in the demo, so the account
* switcher and the merged inbox have something to merge.
*/
import type { Email, Identity } from '@/lib/jmap/types';
import { ago, at, authPass, dayName, listHeaders, mail, mid } from './mail';
import { attachment } from './assets';
import { PMB } from './mailboxes';
import { addr, NORA_PERSONAL, SOFIA, ANNA, ELIAS, CARLOS, MARCUS, EMMA, DAVID, DANIEL, PERSONAL_DOMAIN } from './people';
const me = addr(NORA_PERSONAL);
export function createDemoPersonalIdentities(): Identity[] {
return [{
id: 'demo-identity-personal',
name: 'Nora Lindqvist',
email: NORA_PERSONAL.email,
textSignature: 'Nora',
htmlSignature: '<p>Nora</p>',
mayDelete: false,
}];
}
export function createDemoPersonalEmails(): Email[] {
return [
// ══ Inbox ═══════════════════════════════════════════════════
mail({
id: 'demo-personal-dhl', mailbox: PMB.inbox, date: ago(1, 20),
from: { name: 'DHL', email: 'noreply@dhl.de' }, to: [me],
subject: 'Your parcel arrives today between 14:00 and 17:00',
text: 'Your parcel from Cooper Creative is out for delivery.\n\nTracking: JJD0002 2345 6789 01\nDelivery window: today, 14:00 to 17:00\n\nIf nobody is home, we will try again tomorrow or leave it at the Packstation on Torstraße.',
headers: authPass('dhl.de'),
}),
// Mom, a thread with Nora's reply in Sent.
mail({
id: 'demo-personal-mom-3', thread: 'demo-thread-mom', mailbox: PMB.inbox, date: ago(2, 15),
from: addr(SOFIA), to: [me],
subject: 'Re: when are you coming home?',
text: `Lemons are not a bribe, they are a fact. Twelve of them.\n\nAnna says she can do the weekend after your trip. Your father is already planning the menu, which should worry you. Tell me by ${dayName(4)} so I can tell him.\n\nMom`,
keywords: ['$label:family'],
headers: authPass('example.com'),
inReplyTo: [mid('demo-personal-mom-2', PERSONAL_DOMAIN)], references: [mid('demo-personal-mom-1', 'example.com'), mid('demo-personal-mom-2', PERSONAL_DOMAIN)],
}),
mail({
id: 'demo-personal-mom-1', thread: 'demo-thread-mom', mailbox: PMB.inbox, date: at(1, 20, 48),
from: addr(SOFIA), to: [me],
subject: 'when are you coming home?',
text: `Hi sweetie,\n\nYour father and I were just talking, we miss you. Any chance you can come down for a weekend before the winter? Anna said she might manage the same one, it would be nice to all be in one place again.\n\nThe lemon tree finally fruited! Twelve lemons. I'll save you some.\n\nLove,\nMom`,
keywords: ['$seen', '$label:family'],
headers: authPass('example.com'),
}),
mail({
id: 'demo-personal-anna', mailbox: PMB.inbox, date: at(1, 22, 31),
from: addr(ANNA), to: [me],
subject: 'photos from the wedding',
text: 'ok finally got around to going through these. there are like 600 more on the drive but here are the highlights, the ones I\'d actually want to print.\n\nmom looked SO happy. dad cried during the speech btw, did you see?\n\nlet me know which ones you want full-res of\n\na',
attachments: [attachment('demo-blob-wedding-1'), attachment('demo-blob-wedding-2'), attachment('demo-blob-wedding-3')],
keywords: ['$label:family'],
headers: authPass('example.com'),
}),
mail({
id: 'demo-personal-daniel', mailbox: PMB.inbox, date: at(1, 14, 15),
from: addr(DANIEL), to: [me],
subject: 'Lemon Tree cover, draft 2',
text: 'Hi Nora,\n\nDraft two attached. I made the sun bolder as you asked and moved the title down so it sits in the sea. Still not sure about the serif; a geometric sans would feel more modern but less like your mother.\n\nThe print proof is on its way to you, DHL says tomorrow.\n\nDaniel',
attachments: [attachment('demo-blob-book-cover')],
keywords: ['$seen', '$flagged', '$label:family'],
headers: authPass('coopercreative.example'),
}),
mail({
id: 'demo-personal-carlos', mailbox: PMB.inbox, date: at(2, 21, 10),
from: addr(CARLOS), to: [me],
subject: 'dinner moved to 19:30 (sorry!)',
text: `Quick heads up, had to push dinner back half an hour. The bistro could only do the late seating.\n\nNew time: ${dayName(3)}, 19:30\nThe Garden Bistro, Oranienstraße 12\n\nReservation under my name, eight people. Let me know if that doesn't work and I'll try to wrangle something.\n\nCarlos`,
keywords: ['$seen', '$flagged'],
headers: authPass('example.com'),
}),
mail({
id: 'demo-personal-bookclub', mailbox: PMB.inbox, date: at(1, 12, 40),
from: addr(MARCUS), to: [me, addr(EMMA), addr(DAVID)],
subject: `book club ${dayName(2)}: picking the next one`,
text: `Reminder: 19:00 at mine. We finish off Le Guin and pick the next read.\n\nMy vote is the Calvino but I know Emma has been pushing for the Knausgaard. I'll bring wine, can someone else handle snacks?\n\nm`,
headers: authPass('example.com'),
}),
mail({
id: 'demo-personal-elias', mailbox: PMB.inbox, date: at(3, 18, 5),
from: addr(ELIAS), to: [me, addr(ANNA)],
subject: 'Christmas: Göteborg or Bologna?',
text: 'Before Mom decides for us: do we do Christmas up here or down there this year? I can host, the flat finally has a table that seats six. Anna, your call since you travel furthest.\n\nE',
keywords: ['$seen', '$label:family'],
headers: authPass(PERSONAL_DOMAIN),
}),
mail({
id: 'demo-personal-dentist', mailbox: PMB.inbox, date: at(1, 8, 0),
from: { name: 'Zahnarztpraxis Dr. Weber', email: 'termine@dr-weber.example' }, to: [me],
subject: `Appointment reminder: ${dayName(7)} at 10:00`,
text: `Dear Ms Lindqvist,\n\nThis is a reminder of your appointment on ${dayName(7)} at 10:00 with Dr. Weber (check-up and cleaning).\n\nPractice: Kastanienallee 21, 10435 Berlin, 2nd floor\n\nTo reschedule, reply to this mail or call 030 555 0142.\n\nKind regards,\nZahnarztpraxis Dr. Weber`,
headers: authPass('dr-weber.example'),
}),
// Spotify, HTML with a remote image.
mail({
id: 'demo-personal-spotify', mailbox: PMB.inbox, date: at(2, 9, 30),
from: { name: 'Spotify', email: 'no-reply@spotify.com' }, to: [me],
subject: 'New release from Big Thief',
text: 'Big Thief just released a new album, "Double Infinity Sessions". Based on your listening, we think you\'ll like it.\n\nOpen Spotify to listen.',
html: '<div style="max-width:560px;margin:0 auto;font-family:-apple-system,sans-serif;background:#121212;color:#fff;padding:24px;border-radius:8px"><img src="https://i.scdn.co/image/demo/big-thief-double-infinity.jpg" alt="Album cover" width="512" height="512" style="display:block;width:100%;height:auto;border-radius:4px"><h1 style="font-size:22px;margin:20px 0 6px">New release from Big Thief</h1><p style="color:#b3b3b3;margin:0 0 20px">"Double Infinity Sessions". Based on your listening, we think you\'ll like it.</p><a href="https://open.spotify.com/album/demo" style="display:inline-block;background:#1db954;color:#000;font-weight:700;padding:12px 28px;border-radius:999px;text-decoration:none">Listen now</a></div>',
keywords: ['$seen'],
headers: { ...authPass('spotify.com'), ...listHeaders('Spotify Releases', 'spotify.com') },
}),
mail({
id: 'demo-personal-apple', mailbox: PMB.inbox, date: at(4, 16, 22),
from: { name: 'Apple', email: 'no_reply@email.apple.com' }, to: [me],
subject: 'Your receipt from Apple',
text: 'Receipt\n\niCloud+ 200 GB, monthly · EUR 2.99\nBilled to: Visa ending 1187\n\nApple Distribution International Ltd.',
keywords: ['$seen', '$label:finance'],
headers: authPass('email.apple.com'),
}),
mail({
id: 'demo-personal-marginalian', mailbox: PMB.inbox, date: at(5, 7, 15),
from: { name: 'The Marginalian', email: 'newsletter@themarginalian.org' }, to: [me],
subject: 'Ursula K. Le Guin on the art of growing older, and a forgotten astronomer',
text: 'This week: Le Guin on the art of growing older, a forgotten astronomer who mapped the night sky by hand, and a children\'s book about a lighthouse.\n\nThe Marginalian is free and ad-free; it survives on donations.',
keywords: ['$seen'],
headers: { ...authPass('themarginalian.org'), ...listHeaders('The Marginalian', 'themarginalian.org') },
}),
// ══ Sent ═══════════════════════════════════════════════════
mail({
id: 'demo-personal-mom-2', thread: 'demo-thread-mom', mailbox: PMB.sent, date: at(1, 21, 30),
from: me, to: [addr(SOFIA)],
subject: 'Re: when are you coming home?',
text: 'Mom, I miss you too. I\'m in Lisbon for work next week, but the weekend after could work. Let me check with Anna.\n\nLemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.',
keywords: ['$seen', '$label:family'],
inReplyTo: [mid('demo-personal-mom-1', 'example.com')], references: [mid('demo-personal-mom-1', 'example.com')],
}),
mail({
id: 'demo-personal-sent-daniel', mailbox: PMB.sent, date: at(4, 20, 5),
from: me, to: [addr(DANIEL)],
subject: 'Re: Lemon Tree cover, draft 1',
text: 'Daniel, it\'s lovely. Two things: a bolder sun, and the title lower, into the sea. Keep the serif for now; it is her kind of book.\n\nNora',
keywords: ['$seen', '$label:family'],
}),
// ══ Family folder ══════════════════════════════════════════
mail({
id: 'demo-personal-family-recipe', mailbox: PMB.family, date: at(30, 11, 0),
from: addr(SOFIA), to: [me, addr(ANNA), addr(ELIAS)],
subject: 'Nonna\'s lemon cake, finally written down',
text: 'As promised, and before I forget it myself:\n\n4 eggs, 200 g sugar, 120 g butter, 250 g flour, zest and juice of two lemons (ours, when they come), a pinch of salt, 1 tsp baking powder.\n\nBeat eggs and sugar until pale. Everything else in, lemon last. 45 minutes at 170. Do not open the oven, Elias.\n\nMom',
keywords: ['$seen', '$label:family'], headers: authPass('example.com'),
}),
// ══ Trash ══════════════════════════════════════════════════
mail({
id: 'demo-personal-trash', mailbox: PMB.trash, date: at(6, 12, 0),
from: { name: 'Lieferando', email: 'noreply@lieferando.de' }, to: [me],
subject: 'Your order is on its way',
text: 'Your order from Thai Orchid is on its way and should arrive in about 25 minutes.',
keywords: ['$seen'], headers: authPass('lieferando.de'),
}),
];
}
+76
View File
@@ -0,0 +1,76 @@
/**
* The shared Support mailbox (support@meridianlabs.example). It belongs to a
* separate principal that has delegated it to Nora, so it shows up under her
* own folders as a shared account, and in the merged inbox with its own badge.
*/
import type { Email } from '@/lib/jmap/types';
import { ago, at, authPass, dayName, mail, mid } from './mail';
import { SMB } from './mailboxes';
import { addr, SUPPORT, NORA, TOMAS, LENA, YUSUF } from './people';
const support = addr(SUPPORT);
export function createDemoSupportEmails(): Email[] {
return [
mail({
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?',
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'),
}),
mail({
id: 'demo-support-lena-address', mailbox: SMB.inbox, date: at(1, 11, 18),
from: addr(LENA), to: [support],
subject: 'Invoice address change',
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'),
}),
mail({
id: 'demo-support-yusuf-2', thread: 'demo-support-thread-yusuf', mailbox: SMB.inbox, date: at(1, 9, 5),
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'],
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')],
}),
mail({
id: 'demo-support-yusuf-1', thread: 'demo-support-thread-yusuf', mailbox: SMB.inbox, date: at(2, 15, 42),
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'],
headers: authPass('demir-retail.example'),
}),
mail({
id: 'demo-support-lena-reset', mailbox: SMB.inbox, date: at(3, 10, 2),
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'],
headers: authPass('hoffmann-partner.example'),
}),
// Nora answered Yusuf from the shared mailbox.
mail({
id: 'demo-support-yusuf-reply', thread: 'demo-support-thread-yusuf', mailbox: SMB.sent, date: at(2, 16, 30),
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'],
inReplyTo: [mid('demo-support-yusuf-1', 'demir-retail.example')], references: [mid('demo-support-yusuf-1', 'demir-retail.example')],
}),
mail({
id: 'demo-support-resolved-sso', mailbox: SMB.resolved, date: at(9, 13, 20),
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'),
}),
];
}
/** The persona's address inside the shared mailbox, for the identity list. */
export const SUPPORT_SENDER = { name: `${NORA.given} (Meridian Support)`, email: SUPPORT.email };
+45 -133
View File
@@ -1,140 +1,52 @@
import type { CalendarTask } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
import { CAL } from './calendars';
type Progress = 'needs-action' | 'in-process' | 'completed';
function task(id: string, calendar: string, title: string, due: string, extra: { description?: string; progress?: Progress; priority?: number; allDay?: boolean; alert?: boolean; created?: string } = {}): CalendarTask {
const progress = extra.progress ?? 'needs-action';
return {
id,
calendarIds: { [calendar]: true },
'@type': 'Task',
uid: `${id}@meridianlabs.example`,
title,
description: extra.description ?? '',
due,
start: null,
duration: null,
timeZone: null,
showWithoutTime: extra.allDay ?? true,
progress,
progressUpdated: progress === 'needs-action' ? null : demoDate(0, -3),
priority: extra.priority ?? 0,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: extra.created ?? demoDate(-5),
updated: demoDate(-1),
recurrenceRules: null,
alerts: extra.alert ? {
'alert-1': { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'start' }, action: 'display', acknowledged: null, relatedTo: null },
} : null,
relatedTo: null,
} as CalendarTask;
}
export function createDemoCalendarTasks(): CalendarTask[] {
return [
{
id: 'demo-task-1',
calendarIds: { 'demo-calendar-personal': true },
'@type': 'Task',
uid: 'demo-task-uid-1',
title: 'Buy groceries',
description: 'Milk, bread, eggs, and vegetables',
due: demoDate(0, 2),
start: null,
duration: null,
timeZone: null,
showWithoutTime: false,
progress: 'needs-action',
progressUpdated: null,
priority: 0,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-3),
updated: demoDate(-1),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
{
id: 'demo-task-2',
calendarIds: { 'demo-calendar-work': true },
'@type': 'Task',
uid: 'demo-task-uid-2',
title: 'Prepare quarterly report',
description: 'Compile Q4 metrics and send to team',
due: demoDate(1, 4),
start: null,
duration: null,
timeZone: null,
showWithoutTime: false,
progress: 'in-process',
progressUpdated: demoDate(-1),
priority: 1,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-5),
updated: demoDate(0),
recurrenceRules: null,
alerts: {
'demo-alert-1': {
'@type': 'Alert',
trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' },
action: 'display',
acknowledged: null,
relatedTo: null,
},
},
relatedTo: null,
},
{
id: 'demo-task-3',
calendarIds: { 'demo-calendar-personal': true },
'@type': 'Task',
uid: 'demo-task-uid-3',
title: 'Schedule dentist appointment',
description: '',
due: demoDate(3),
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'needs-action',
progressUpdated: null,
priority: 5,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-2),
updated: demoDate(-2),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
{
id: 'demo-task-4',
calendarIds: { 'demo-calendar-work': true },
'@type': 'Task',
uid: 'demo-task-uid-4',
title: 'Review pull requests',
description: 'Review open PRs from the team',
due: demoDate(-1),
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'completed',
progressUpdated: demoDate(0),
priority: 0,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-4),
updated: demoDate(0),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
{
id: 'demo-task-5',
calendarIds: { 'demo-calendar-personal': true },
'@type': 'Task',
uid: 'demo-task-uid-5',
title: 'Pay electricity bill',
description: '',
due: demoDate(-2),
start: null,
duration: null,
timeZone: null,
showWithoutTime: true,
progress: 'needs-action',
progressUpdated: null,
priority: 1,
privacy: 'public',
keywords: null,
categories: null,
color: null,
created: demoDate(-7),
updated: demoDate(-7),
recurrenceRules: null,
alerts: null,
relatedTo: null,
},
task('demo-task-runbook', CAL.work, 'Rollback runbook: work in Bob\'s comments', demoDate(1, 2), { description: 'Name the dashboard owner in step 3; drop the signing-key question.', progress: 'in-process', priority: 1, allDay: false, alert: true }),
task('demo-task-rsvp', CAL.work, 'RSVP to the go / no-go review', demoDate(0, 4), { priority: 1, allDay: false }),
task('demo-task-quote', CAL.work, 'Quote for Rachel\'s launch post', demoDate(3), { description: 'Two sentences. Not "we are excited".', priority: 5 }),
task('demo-task-canary-list', CAL.work, 'Pick the five canary tenants with Priya', demoDate(4), { description: 'Lumen Logistics is one.' }),
task('demo-task-slides', CAL.work, 'Finish Infra Summit slides', demoDate(8), { progress: 'in-process', priority: 5, created: demoDate(-12) }),
task('demo-task-review-pr', CAL.work, 'Review #1284 (signing key overlap)', demoDate(-1), { progress: 'completed', created: demoDate(-3) }),
task('demo-task-invoice', CAL.work, 'Approve DesignCo invoice 2026-089', demoDate(2), { priority: 9 }),
task('demo-task-mom', CAL.personal, 'Tell Mom which weekend', demoDate(4), { priority: 5 }),
task('demo-task-suit', CAL.personal, 'Pick up the suit from the cleaner', demoDate(-2), { priority: 9 }),
task('demo-task-passport', CAL.personal, 'Check passport is in the travel wallet', demoDate(8), { progress: 'completed' }),
task('demo-task-snacks', CAL.personal, 'Snacks for book club', demoDate(2)),
];
}
+43
View File
@@ -0,0 +1,43 @@
import type { EmailTemplate } from '@/lib/template-types';
import { demoDate } from '../demo-utils';
/** Templates seeded into the (device-local) template store on demo login. */
export function createDemoTemplates(): EmailTemplate[] {
return [
{
id: 'demo-template-followup',
name: 'Meeting follow-up',
subject: 'Follow-up: {{subject}}',
body: '<p>Hi {{recipient_name}},</p><p>Thanks for your time today. What we agreed:</p><ul><li></li><li></li></ul><p>Next step is on my side; I\'ll come back to you by {{date}}.</p><p>Nora</p>',
isHTML: true,
category: 'Work',
isFavorite: true,
createdAt: demoDate(-90),
updatedAt: demoDate(-20),
},
{
id: 'demo-template-webhook',
name: 'Support: webhook signature help',
subject: 'Re: Webhook signature verification',
body: '<p>Hi {{recipient_name}},</p><p>The two things that usually go wrong with signature verification:</p><ol><li>Sign the raw request body, not the decoded JSON.</li><li>The timestamp in <code>X-Beacon-Timestamp</code> is in seconds and the tolerance is five minutes.</li></ol><p>If that does not fix it, send me one failing request (headers and body) and I\'ll look at it with you.</p><p>{{sender_name}}, for Meridian Support</p>',
isHTML: true,
category: 'Support',
identityId: 'demo-identity-support',
isFavorite: false,
createdAt: demoDate(-40),
updatedAt: demoDate(-2),
},
{
id: 'demo-template-handover',
name: 'Handover before leave',
subject: 'Handover: {{day_of_week}} to next week',
body: '<p>Hi all,</p><p>I\'m out from {{date}}. While I\'m away:</p><ul><li>Launch questions: Alice</li><li>Customer questions: support@meridianlabs.example</li><li>Anything on fire: call me, don\'t mail</li></ul><p>Nora</p>',
isHTML: true,
category: 'Work',
defaultRecipients: { to: ['alice.johnson@meridianlabs.example'], cc: ['bob.chen@meridianlabs.example'] },
isFavorite: false,
createdAt: demoDate(-200),
updatedAt: demoDate(-200),
},
];
}
+7 -5
View File
@@ -1,13 +1,15 @@
import type { VacationResponse } from '@/lib/jmap/types';
import { demoDate } from '../demo-utils';
/** Prepared for the Lisbon trip, not switched on yet. */
export function createDemoVacationResponse(): VacationResponse {
return {
id: 'singleton',
isEnabled: false,
fromDate: null,
toDate: null,
subject: 'Out of Office',
textBody: 'Thank you for your email. I am currently out of the office and will return on Monday. For urgent matters, please contact support@example.com.',
htmlBody: '<p>Thank you for your email. I am currently out of the office and will return on Monday.</p><p>For urgent matters, please contact <a href="mailto:support@example.com">support@example.com</a>.</p>',
fromDate: demoDate(9, 0),
toDate: demoDate(13, 0),
subject: 'Out of office: back next week',
textBody: 'Thank you for your mail. I am at Infra Summit in Lisbon and reading mail slowly until next week.\n\nFor anything about the Beacon launch, Alice Johnson (alice.johnson@meridianlabs.example) has the plan. For customer questions, support@meridianlabs.example answers within a business day.\n\nNora',
htmlBody: '<p>Thank you for your mail. I am at Infra Summit in Lisbon and reading mail slowly until next week.</p><p>For anything about the Beacon launch, <a href="mailto:alice.johnson@meridianlabs.example">Alice Johnson</a> has the plan. For customer questions, <a href="mailto:support@meridianlabs.example">support@meridianlabs.example</a> answers within a business day.</p><p>Nora</p>',
};
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
View File
@@ -0,0 +1,16 @@
# Beacon weekly · 17 Oct 2026
Attendees: Alice, Bob, Priya, Ahmed, Lisa, Nora
## Decisions
- Canary stays at 5% for a full 24 h, not 12. Priya wants one night of traffic.
- Webhook signing keys rotate every 90 days; the old key stays valid for 48 h.
- Rachel gets the changelog draft by 22 Oct 2026.
## Open
- [ ] Rollback runbook: Nora, review by Bob and Ahmed
- [ ] Long-poll reconnect test on the staging tenant: Lisa
- [ ] Status page component for Beacon: Ahmed
## Notes
Load test held at 3× peak for 48 h without a retry-budget breach. p95 184 ms.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
(A) Rollback runbook, send to Bob and Ahmed @beacon due:2026-10-21
(A) RSVP go/no-go review @beacon
(B) Book airport train for 27 Oct 2026 @travel
(B) Reply to Mom about the weekend @family
(C) Pick up suit from the cleaner @errands
x Sign DesignCo invoice 2026-089 @finance
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env node
/**
* Generate the binary files behind the demo fixtures into public/demo/.
*
* node scripts/demo-assets/generate.mjs [outDir]
*
* Every attachment and every entry in the demo Files app points at one of
* these, so previews render real content instead of a text placeholder. The
* PDFs and PNGs are rendered from the HTML templates below with the Chromium
* that Playwright already ships for the test suite; the spreadsheets use
* exceljs (a dependency of the app). Word and PowerPoint files need `docx` and
* `pptxgenjs`, which are not dependencies of the app: install them anywhere
* and point DEMO_ASSET_DEPS at that folder, e.g.
*
* mkdir /tmp/deps && (cd /tmp/deps && npm init -y && npm i docx pptxgenjs)
* DEMO_ASSET_DEPS=/tmp/deps node scripts/demo-assets/generate.mjs
*
* Without them the Office files are skipped and the existing ones are kept.
* The output is committed; rerun only when the fixtures change.
*/
import fs from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(here, '..', '..');
const OUT = path.resolve(process.argv[2] || path.join(ROOT, 'public', 'demo'));
const require = createRequire(path.join(ROOT, 'package.json'));
const { chromium } = require('playwright-core');
const ExcelJS = require('exceljs');
let docx = null;
let PptxGenJS = null;
if (process.env.DEMO_ASSET_DEPS) {
const depRequire = createRequire(path.join(path.resolve(process.env.DEMO_ASSET_DEPS), 'package.json'));
try { docx = depRequire('docx'); } catch { /* skipped below */ }
try { PptxGenJS = depRequire('pptxgenjs'); } catch { /* skipped below */ }
}
fs.mkdirSync(OUT, { recursive: true });
const written = [];
const write = (name, buf) => {
fs.writeFileSync(path.join(OUT, name), buf);
written.push(`${name} ${(buf.length / 1024).toFixed(0)} KB`);
};
// ── Shared look for the rendered documents ─────────────────────────────
const CSS = `
@page { size: A4; margin: 0; }
* { box-sizing: border-box; }
body { margin: 0; font: 11pt/1.5 "Segoe UI", Inter, -apple-system, Helvetica, Arial, sans-serif; color: #18181b; }
.page { width: 210mm; min-height: 297mm; padding: 22mm 20mm; position: relative; }
h1 { font-size: 26pt; margin: 0 0 4mm; letter-spacing: -0.01em; }
h2 { font-size: 13pt; margin: 10mm 0 3mm; }
.muted { color: #71717a; }
.brand { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 14mm; }
.logo { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; font-size: 14pt; }
.logo i { width: 20px; height: 20px; border-radius: 6px; display: inline-block; }
table { width: 100%; border-collapse: collapse; font-size: 10.5pt; }
th { text-align: left; font-weight: 600; color: #52525b; border-bottom: 1.5px solid #18181b; padding: 2.5mm 0; }
td { padding: 2.5mm 0; border-bottom: 1px solid #e4e4e7; vertical-align: top; }
td.r, th.r { text-align: right; font-variant-numeric: tabular-nums; }
.total td { border-bottom: 0; font-weight: 700; font-size: 12pt; padding-top: 4mm; }
.box { border: 1px solid #e4e4e7; border-radius: 8px; padding: 6mm; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6mm; }
.tag { display: inline-block; font-size: 8.5pt; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: #f4f4f5; color: #3f3f46; }
.foot { position: absolute; left: 20mm; right: 20mm; bottom: 14mm; font-size: 8.5pt; color: #a1a1aa; border-top: 1px solid #e4e4e7; padding-top: 3mm; display: flex; justify-content: space-between; }
.big { font-size: 30pt; font-weight: 700; letter-spacing: -0.02em; }
.mono { font-family: ui-monospace, Consolas, Menlo, monospace; }
`;
const page = (body) => `<!doctype html><html><head><meta charset="utf-8"><style>${CSS}</style></head><body><div class="page">${body}</div></body></html>`;
const logo = (name, color) => `<span class="logo"><i style="background:${color}"></i>${name}</span>`;
// Dates in the documents are phrased relative to "this month" so they do not
// contradict the fixtures, which float with the clock.
const now = new Date();
const month = now.toLocaleString('en-GB', { month: 'long' });
const year = now.getFullYear();
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).toLocaleString('en-GB', { month: 'long' });
const fmt = (d) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
const plus = (days) => { const d = new Date(now); d.setDate(d.getDate() + days); return d; };
const DOCS = {
// DesignCo invoice, attached to Sarah Kim's mail.
'invoice-designco-2026-089.pdf': page(`
<div class="brand">${logo('DesignCo', '#0f8578')}<div class="muted" style="text-align:right">DesignCo Studio GmbH<br>Torstraße 112, 10119 Berlin<br>hello@designco.example</div></div>
<h1>Invoice 2026-089</h1>
<div class="grid" style="margin-bottom:10mm">
<div><div class="muted">Billed to</div>Meridian Labs GmbH<br>Attn. Nora Lindqvist<br>Rosenthaler Str. 40, 10178 Berlin</div>
<div><div class="muted">Details</div>Issued ${fmt(now)}<br>Due ${fmt(plus(14))}<br>Project: Website redesign</div>
</div>
<table><tr><th>Description</th><th class="r">Hours</th><th class="r">Rate</th><th class="r">Amount</th></tr>
<tr><td>Landing page prototype, v3 (hero option B, asymmetric grid)</td><td class="r">24</td><td class="r">€110.00</td><td class="r">€2,640.00</td></tr>
<tr><td>Design system tokens and component audit</td><td class="r">12</td><td class="r">€110.00</td><td class="r">€1,320.00</td></tr>
<tr><td>Two review calls and revisions</td><td class="r">4</td><td class="r">€110.00</td><td class="r">€440.00</td></tr>
<tr><td class="muted">Subtotal</td><td></td><td></td><td class="r">€4,400.00</td></tr>
<tr><td class="muted">VAT 19%</td><td></td><td></td><td class="r">€836.00</td></tr>
<tr class="total"><td>Total due</td><td></td><td></td><td class="r">€5,236.00</td></tr></table>
<h2>Payment</h2><p>Bank transfer to DesignCo Studio GmbH, IBAN DE02 1203 0000 0000 2020 51, reference <span class="mono">2026-089</span>. The ${nextMonth} pre-payment has been backed out as agreed.</p>
<div class="foot"><span>DesignCo Studio GmbH · HRB 98765 B · VAT DE 812 345 678</span><span>Page 1 of 1</span></div>`),
// Hotel voucher for the Lisbon trip.
'hotel-voucher-lisbon.pdf': page(`
<div class="brand">${logo('Booking.com', '#003580')}<span class="tag">Confirmed</span></div>
<h1>Hotel Tejo Vista, Lisbon</h1>
<p class="muted">Rua do Alecrim 48, 1200-018 Lisboa, Portugal · +351 21 555 0170</p>
<div class="grid" style="margin:8mm 0">
<div class="box"><div class="muted">Check-in</div><div class="big">${plus(9).getDate()}</div>${plus(9).toLocaleDateString('en-GB', { weekday: 'long', month: 'long' })}<br>from 15:00</div>
<div class="box"><div class="muted">Check-out</div><div class="big">${plus(12).getDate()}</div>${plus(12).toLocaleDateString('en-GB', { weekday: 'long', month: 'long' })}<br>until 11:00</div>
</div>
<table><tr><th>Room</th><th class="r">Nights</th><th class="r">Price</th></tr>
<tr><td>Superior double, river view, breakfast included</td><td class="r">3</td><td class="r">€486.00</td></tr>
<tr><td class="muted">City tax (paid at the property)</td><td></td><td class="r">€12.00</td></tr>
<tr class="total"><td>Paid</td><td></td><td class="r">€486.00</td></tr></table>
<h2>Confirmation</h2><p>Number <span class="mono">4892.771.433</span> · PIN <span class="mono">2047</span>. Show this voucher at reception. Free cancellation ended ${fmt(plus(2))}.</p>
<div class="foot"><span>Guest: Nora Lindqvist · 1 adult</span><span>Booking.com B.V.</span></div>`),
// Boarding pass BER → LIS.
'boarding-pass-ber-lis.pdf': page(`
<div class="brand">${logo('Lufthansa', '#05164d')}<span class="tag">Boarding pass</span></div>
<div class="box" style="padding:10mm">
<div style="display:flex;justify-content:space-between;align-items:flex-end">
<div><div class="muted">Berlin</div><div class="big">BER</div></div>
<div class="muted" style="font-size:20pt">→</div>
<div style="text-align:right"><div class="muted">Lisbon</div><div class="big">LIS</div></div>
</div>
<div class="grid" style="margin-top:10mm;grid-template-columns:repeat(4,1fr)">
<div><div class="muted">Flight</div><b>LH 1796</b></div>
<div><div class="muted">Date</div><b>${fmt(plus(9))}</b></div>
<div><div class="muted">Boarding</div><b>07:55</b></div>
<div><div class="muted">Departure</div><b>08:35</b></div>
<div><div class="muted">Passenger</div><b>LINDQVIST / NORA</b></div>
<div><div class="muted">Seat</div><b>14A</b></div>
<div><div class="muted">Gate</div><b>B21</b></div>
<div><div class="muted">Class</div><b>Economy</b></div>
</div>
<div style="margin-top:10mm;height:22mm;background:repeating-linear-gradient(90deg,#18181b 0 2px,transparent 2px 5px,#18181b 5px 6px,transparent 6px 9px)"></div>
<div class="mono muted" style="margin-top:2mm">M1LINDQVIST/NORA E7KQ2R BERLISLH 1796 ${String(plus(9).getDate()).padStart(2, '0')}Y014A0031 100</div>
</div>
<p class="muted" style="margin-top:8mm">Be at the gate 30 minutes before departure. Hand baggage: 1 piece up to 8 kg.</p>
<div class="foot"><span>Booking reference E7KQ2R · Miles &amp; More 992 004 118 221</span><span>Deutsche Lufthansa AG</span></div>`),
// Conference ticket.
'infra-summit-ticket.pdf': page(`
<div class="brand">${logo('Infra Summit Lisbon', '#db2d54')}<span class="tag">Speaker</span></div>
<h1>Nora Lindqvist</h1>
<p class="muted">Meridian Labs · Talk: "Rolling back without rolling over: safe launches for event platforms"</p>
<div class="grid" style="margin:8mm 0">
<div class="box"><div class="muted">When</div><b>${fmt(plus(10))} to ${fmt(plus(11))}</b><br>Doors 08:30, talks from 09:30</div>
<div class="box"><div class="muted">Where</div><b>Centro de Congressos de Lisboa</b><br>Praça das Indústrias 1, Lisboa</div>
</div>
<div class="box" style="display:flex;gap:8mm;align-items:center">
<div style="width:36mm;height:36mm;background:
conic-gradient(#18181b 25%, transparent 0 50%, #18181b 0 75%, transparent 0) 0 0/12mm 12mm, #fff; border:4px solid #18181b"></div>
<div><div class="muted">Ticket</div><div class="mono" style="font-size:14pt">IS26-SPK-01827</div><div class="muted" style="margin-top:3mm">Includes the speakers' dinner on day one and the workshop day.</div></div>
</div>
<div class="foot"><span>Issued by Infra Summit Lda. via Tito</span><span>Non-transferable</span></div>`),
// Beacon launch plan (the PDF in the Projects folder).
'beacon-launch-plan-v3.pdf': page(`
<div class="brand">${logo('Meridian Labs', '#db2d54')}<span class="tag">Internal · v3</span></div>
<h1>Beacon launch plan</h1>
<p class="muted">Owner: Michael Torres · Engineering: Alice Johnson, Nora Lindqvist · Launch ${fmt(plus(8))}</p>
<h2>What ships</h2>
<p>Beacon replaces the polling notification API with a push platform: webhooks with signed deliveries, server-sent events for browsers, and a retry queue with per-tenant budgets. Customers on the current API keep working until ${nextMonth} ${year + 1}.</p>
<h2>Timeline</h2>
<table><tr><th>Day</th><th>Milestone</th><th>Owner</th><th>State</th></tr>
<tr><td>${fmt(plus(-6))}</td><td>Load test at 3× peak, 48 h soak</td><td>Ahmed</td><td><span class="tag">done</span></td></tr>
<tr><td>${fmt(plus(1))}</td><td>Go / no-go review</td><td>Alice</td><td><span class="tag">scheduled</span></td></tr>
<tr><td>${fmt(plus(3))}</td><td>Rollback runbook signed off</td><td>Nora</td><td><span class="tag">in review</span></td></tr>
<tr><td>${fmt(plus(6))}</td><td>Canary: 5% of tenants, 24 h</td><td>Ahmed, Priya</td><td><span class="tag">planned</span></td></tr>
<tr><td>${fmt(plus(8))}</td><td>General availability, blog post, changelog</td><td>Rachel</td><td><span class="tag">planned</span></td></tr></table>
<h2>Risks</h2>
<p>Webhook retries during a partial outage can double delivery volume; the budget caps that at 2× per tenant. The old API's long-poll clients see one reconnect at cutover. Both are covered in the runbook.</p>
<div class="foot"><span>Meridian Labs GmbH · Confidential</span><span>Page 1 of 1</span></div>`),
// Hetzner invoice in Receipts.
'hetzner-invoice.pdf': page(`
<div class="brand">${logo('Hetzner', '#d50c2d')}<div class="muted" style="text-align:right">Hetzner Online GmbH<br>Industriestr. 25, 91710 Gunzenhausen</div></div>
<h1>Invoice R0012345678</h1>
<p class="muted">Customer K0987654 · Meridian Labs GmbH · Period ${month} ${year}</p>
<table style="margin-top:8mm"><tr><th>Product</th><th>Location</th><th class="r">Amount</th></tr>
<tr><td>Cloud server CAX31 (beacon-eu-1)</td><td>Falkenstein</td><td class="r">€14.69</td></tr>
<tr><td>Cloud server CAX31 (beacon-eu-2)</td><td>Helsinki</td><td class="r">€14.69</td></tr>
<tr><td>Load balancer LB11</td><td>Falkenstein</td><td class="r">€5.83</td></tr>
<tr><td>Object storage, 412 GB</td><td>Falkenstein</td><td class="r">€8.24</td></tr>
<tr><td class="muted">VAT 19%</td><td></td><td class="r">€8.26</td></tr>
<tr class="total"><td>Total (paid by SEPA direct debit)</td><td></td><td class="r">€51.71</td></tr></table>
<div class="foot"><span>Hetzner Online GmbH · VAT DE 812 871 812</span><span>Page 1 of 1</span></div>`),
// Quarterly report in Files.
'quarterly-report.pdf': page(`
<div class="brand">${logo('Meridian Labs', '#db2d54')}<span class="tag">Board update</span></div>
<h1>Quarterly report</h1>
<p class="muted">Engineering and product · prepared by Nora Lindqvist and Michael Torres</p>
<div class="grid" style="grid-template-columns:repeat(3,1fr);margin:8mm 0">
<div class="box"><div class="muted">Uptime</div><div class="big">99.97%</div></div>
<div class="box"><div class="muted">p95 latency</div><div class="big">184 ms</div></div>
<div class="box"><div class="muted">Active tenants</div><div class="big">1,284</div></div>
</div>
<h2>Highlights</h2>
<p>Beacon reached feature-complete and passed the 48-hour soak at three times peak traffic. The website redesign goes live with the launch. Support volume fell 18% after the status page and the new error pages shipped.</p>
<h2>Next quarter</h2>
<p>Beacon general availability, migration tooling for long-poll customers, and the first external audit of the delivery signing scheme.</p>
<div class="foot"><span>Meridian Labs GmbH · Confidential</span><span>Page 1 of 1</span></div>`),
// Internal purchase order attached to the support thread.
'po-lumen-logistics.pdf': page(`
<div class="brand">${logo('Lumen Logistics', '#1d4ed8')}<span class="tag">Purchase order</span></div>
<h1>PO 2026-0412</h1>
<p class="muted">Lumen Logistics S.A. · Av. da Liberdade 110, Lisboa · to Meridian Labs GmbH</p>
<table style="margin-top:8mm"><tr><th>Item</th><th class="r">Qty</th><th class="r">Amount</th></tr>
<tr><td>Meridian Platform, Business plan, annual</td><td class="r">1</td><td class="r">€11,880.00</td></tr>
<tr><td>Beacon add-on, 50 tenants</td><td class="r">1</td><td class="r">€3,600.00</td></tr>
<tr class="total"><td>Total excl. VAT</td><td></td><td class="r">€15,480.00</td></tr></table>
<p style="margin-top:8mm">Please invoice to accounts@lumenlogistics.example and quote the PO number. Payment terms 30 days.</p>
<div class="foot"><span>Approved: Tomás Ferreira, Head of Operations</span><span>Page 1 of 1</span></div>`),
};
// PNGs rendered from HTML at 2x.
const IMAGES = {
// Screenshot of the Beacon metrics dashboard, attached to Ahmed's soak-test mail.
'beacon-soak-dashboard.png': {
width: 1200, height: 720, html: `<!doctype html><html><head><meta charset="utf-8"><style>
body { margin:0; width:1200px; height:720px; background:#0f1117; color:#e5e7eb; font: 14px/1.4 "Segoe UI", Inter, sans-serif; padding:24px; box-sizing:border-box }
.top { display:flex; justify-content:space-between; align-items:center; margin-bottom:18px }
.top b { font-size:18px } .top span { color:#9ca3af }
.tiles { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:14px }
.tile { background:#171a23; border:1px solid #262a36; border-radius:10px; padding:14px 16px }
.tile small { color:#9ca3af; display:block; margin-bottom:6px } .tile b { font-size:26px; font-weight:600 }
.ok { color:#34d399 } .chart { background:#171a23; border:1px solid #262a36; border-radius:10px; padding:16px; height:420px; position:relative }
.chart small { color:#9ca3af } svg { position:absolute; left:16px; right:16px; bottom:16px; top:44px; width:calc(100% - 32px); height:calc(100% - 60px) }
</style></head><body>
<div class="top"><b>Beacon · soak test, 48 h at 3× peak</b><span>eu-1, eu-2 · last 48 hours</span></div>
<div class="tiles">
<div class="tile"><small>Deliveries / s</small><b>42,380</b></div>
<div class="tile"><small>p95 delivery latency</small><b>184 ms</b></div>
<div class="tile"><small>Retry budget used</small><b class="ok">11%</b></div>
<div class="tile"><small>Errors</small><b class="ok">0.02%</b></div>
</div>
<div class="chart"><small>Deliveries per second</small>
<svg viewBox="0 0 1000 300" preserveAspectRatio="none">
<defs><linearGradient id="g" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#db2d54" stop-opacity=".5"/><stop offset="1" stop-color="#db2d54" stop-opacity="0"/></linearGradient></defs>
${[0.25, 0.5, 0.75].map((f) => `<line x1="0" x2="1000" y1="${300 * f}" y2="${300 * f}" stroke="#262a36"/>`).join('')}
<path d="M0,260 ${Array.from({ length: 48 }, (_, i) => `L${(i + 1) * 20.8},${200 - Math.round(Math.sin(i / 3) * 40 + (i % 7) * 6 + (i > 30 ? -20 : 0))}`).join(' ')} L1000,300 L0,300 Z" fill="url(#g)"/>
<path d="M0,260 ${Array.from({ length: 48 }, (_, i) => `L${(i + 1) * 20.8},${200 - Math.round(Math.sin(i / 3) * 40 + (i % 7) * 6 + (i > 30 ? -20 : 0))}`).join(' ')}" fill="none" stroke="#db2d54" stroke-width="2.5"/>
</svg></div>
</body></html>`,
},
// Illustration draft from Daniel Cooper (the commission thread).
'book-cover-draft.png': {
width: 900, height: 1200, html: `<!doctype html><html><head><meta charset="utf-8"><style>
body { margin:0; width:900px; height:1200px; background:#f3e9d2; font-family: Georgia, serif; position:relative; overflow:hidden }
.sun { position:absolute; width:520px; height:520px; border-radius:50%; background:#e2574c; left:190px; top:140px }
.sea { position:absolute; left:0; right:0; bottom:0; height:520px; background:#1f4e5f }
.wave { position:absolute; left:-40px; right:-40px; height:70px; border-radius:50%; background:#2b6b7f }
h1 { position:absolute; left:70px; top:760px; margin:0; color:#f3e9d2; font-size:88px; line-height:1; font-weight:400; letter-spacing:-0.02em }
p { position:absolute; left:72px; top:960px; margin:0; color:#f3e9d2; font-size:30px; letter-spacing:.25em; text-transform:uppercase }
.note { position:absolute; right:40px; top:40px; font: 20px/1.3 "Segoe UI", sans-serif; color:#8a2f28; transform:rotate(-4deg) }
</style></head><body>
<div class="sun"></div><div class="sea"></div>
<div class="wave" style="bottom:400px"></div><div class="wave" style="bottom:300px;opacity:.7"></div><div class="wave" style="bottom:190px;opacity:.5"></div>
<h1>The Lemon<br>Tree Year</h1><p>Sofia Russo</p>
<div class="note">draft 2 — bolder sun?</div>
</body></html>`,
},
};
// Plain-text files in the Files app.
const TEXT = {
'meeting-notes.md': `# Beacon weekly · ${fmt(plus(-1))}
Attendees: Alice, Bob, Priya, Ahmed, Lisa, Nora
## Decisions
- Canary stays at 5% for a full 24 h, not 12. Priya wants one night of traffic.
- Webhook signing keys rotate every 90 days; the old key stays valid for 48 h.
- Rachel gets the changelog draft by ${fmt(plus(4))}.
## Open
- [ ] Rollback runbook: Nora, review by Bob and Ahmed
- [ ] Long-poll reconnect test on the staging tenant: Lisa
- [ ] Status page component for Beacon: Ahmed
## Notes
Load test held at 3× peak for 48 h without a retry-budget breach. p95 184 ms.
`,
'todo.txt': `(A) Rollback runbook, send to Bob and Ahmed @beacon due:${plus(3).toISOString().slice(0, 10)}
(A) RSVP go/no-go review @beacon
(B) Book airport train for ${fmt(plus(9))} @travel
(B) Reply to Mom about the weekend @family
(C) Pick up suit from the cleaner @errands
x Sign DesignCo invoice 2026-089 @finance
`,
};
// ── Office documents ─────────────────────────────────────────────────────
async function xlsxLaunchMetrics() {
const wb = new ExcelJS.Workbook();
wb.creator = 'Nora Lindqvist';
const ws = wb.addWorksheet('Soak test');
ws.columns = [
{ header: 'Hour', key: 'h', width: 8 },
{ header: 'Deliveries/s', key: 'd', width: 14 },
{ header: 'p95 ms', key: 'p', width: 10 },
{ header: 'Errors %', key: 'e', width: 10 },
{ header: 'Retry budget %', key: 'r', width: 16 },
];
for (let h = 1; h <= 48; h++) {
ws.addRow({ h, d: 40000 + Math.round(Math.sin(h / 3) * 2500 + (h % 7) * 300), p: 170 + (h % 5) * 4 + (h > 30 ? 6 : 0), e: Number((0.01 + (h % 4) * 0.005).toFixed(3)), r: Math.min(14, 4 + Math.round(h / 5)) });
}
ws.getRow(1).font = { bold: true };
ws.views = [{ state: 'frozen', ySplit: 1 }];
const sum = wb.addWorksheet('Summary');
sum.addRows([
['Metric', 'Value', 'Target'],
['Peak deliveries/s', 42380, 36000],
['p95 latency (ms)', 184, 250],
['Error rate', '0.02%', '< 0.1%'],
['Retry budget used', '11%', '< 50%'],
['Verdict', 'Pass', ''],
]);
sum.getRow(1).font = { bold: true };
sum.columns = [{ width: 22 }, { width: 12 }, { width: 12 }];
return Buffer.from(await wb.xlsx.writeBuffer());
}
async function xlsxBudget() {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('Budget');
ws.addRow(['Category', 'Planned', 'Actual', 'Delta']);
const rows = [['Cloud', 7200, 6840], ['Tooling', 2400, 2510], ['Design (DesignCo)', 5000, 5236], ['Conferences', 3000, 1870], ['Hardware', 1500, 0]];
rows.forEach(([c, p, a], i) => {
const r = i + 2;
ws.addRow([c, p, a, { formula: `C${r}-B${r}` }]);
});
ws.addRow(['Total', { formula: 'SUM(B2:B6)' }, { formula: 'SUM(C2:C6)' }, { formula: 'SUM(D2:D6)' }]);
ws.getRow(1).font = { bold: true };
ws.getRow(7).font = { bold: true };
ws.columns = [{ width: 22 }, { width: 12 }, { width: 12 }, { width: 12 }];
for (const col of ['B', 'C', 'D']) ws.getColumn(col).numFmt = '€#,##0';
return Buffer.from(await wb.xlsx.writeBuffer());
}
async function docxRunbook() {
const { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, WidthType } = docx;
const p = (text, opts = {}) => new Paragraph({ children: [new TextRun({ text, ...opts })], spacing: { after: 120 } });
const h = (text, level = HeadingLevel.HEADING_1) => new Paragraph({ text, heading: level, spacing: { before: 240, after: 120 } });
const cell = (text, bold = false) => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text, bold })] })] });
const steps = [
['1', 'Freeze deploys; announce in #beacon-launch', 'On-call'],
['2', 'Flip the tenant router back to the polling API (feature flag beacon.route)', 'Nora'],
['3', 'Drain the webhook queue; retries pause automatically once the flag is off', 'Ahmed'],
['4', 'Confirm long-poll clients reconnected (dashboard: reconnects < 1%)', 'Lisa'],
['5', 'Post the status page update and the customer note', 'Rachel'],
];
const doc = new Document({
creator: 'Nora Lindqvist',
title: 'Beacon rollback runbook',
sections: [{
children: [
new Paragraph({ text: 'Beacon rollback runbook', heading: HeadingLevel.TITLE }),
p('Meridian Labs · draft 2 · owner Nora Lindqvist · reviewers Bob Chen, Ahmed Hassan', { italics: true, color: '666666' }),
h('When to roll back'),
p('Error rate above 0.5% for ten minutes, p95 delivery latency above 1 s, or any retry budget breach on more than three tenants. The on-call engineer decides; nobody needs to be asked first.'),
h('Steps'),
new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [
new TableRow({ children: [cell('#', true), cell('Action', true), cell('Owner', true)] }),
...steps.map(([n, a, o]) => new TableRow({ children: [cell(n), cell(a), cell(o)] })),
] }),
h('Expected impact'),
p('Customers on the new API see deliveries pause for the drain window, at most two minutes. Nothing is lost: the queue is replayed after the fix. Long-poll clients see one reconnect.'),
h('Open questions'),
p('Do we keep the signing keys rotated during the rollback window? Bob to confirm with the auditor.'),
],
}],
});
return Buffer.from(await Packer.toBuffer(doc));
}
async function docxContract() {
const { Document, Packer, Paragraph, TextRun, HeadingLevel } = docx;
const p = (text) => new Paragraph({ children: [new TextRun(text)], spacing: { after: 160 } });
const doc = new Document({
title: 'Service agreement draft',
sections: [{ children: [
new Paragraph({ text: 'Service agreement', heading: HeadingLevel.TITLE }),
p('between Meridian Labs GmbH ("Provider") and Lumen Logistics S.A. ("Customer"). Draft for review, not for signature.'),
new Paragraph({ text: '1. Scope', heading: HeadingLevel.HEADING_1 }),
p('The Provider operates the Meridian Platform and the Beacon notification service for the Customer under the Business plan, as described in Purchase Order 2026-0412.'),
new Paragraph({ text: '2. Service levels', heading: HeadingLevel.HEADING_1 }),
p('Monthly availability of 99.9%, measured at the API edge. Credits of 10% of the monthly fee per 0.1% below target, capped at 50%.'),
new Paragraph({ text: '3. Term', heading: HeadingLevel.HEADING_1 }),
p('Twelve months from the effective date, renewing yearly unless either party gives sixty days notice.'),
] }],
});
return Buffer.from(await Packer.toBuffer(doc));
}
async function pptxLaunchDeck() {
const pres = new PptxGenJS();
pres.layout = 'LAYOUT_16x9';
pres.author = 'Rachel Green';
pres.title = 'Beacon launch';
const rasp = 'DB2D54';
const cover = pres.addSlide();
cover.background = { color: '0F1117' };
cover.addText('Beacon', { x: 0.6, y: 1.6, w: 8, h: 1.2, fontSize: 54, bold: true, color: 'FFFFFF', fontFace: 'Segoe UI' });
cover.addText('Real-time notifications for every Meridian tenant', { x: 0.6, y: 2.8, w: 8, h: 0.6, fontSize: 20, color: 'C7C9D1', fontFace: 'Segoe UI' });
cover.addShape(pres.ShapeType.rect, { x: 0.6, y: 3.6, w: 1.4, h: 0.08, fill: { color: rasp }, line: { color: rasp } });
cover.addText(`Launch ${fmt(plus(8))} · Meridian Labs`, { x: 0.6, y: 4.6, w: 8, h: 0.4, fontSize: 12, color: '8B8E98', fontFace: 'Segoe UI' });
const what = pres.addSlide();
what.addText('What customers get', { x: 0.6, y: 0.4, w: 8, h: 0.7, fontSize: 30, bold: true, color: '18181B', fontFace: 'Segoe UI' });
what.addText([
{ text: 'Webhooks with signed deliveries', options: { bullet: true, breakLine: true } },
{ text: 'Server-sent events for browsers, no polling', options: { bullet: true, breakLine: true } },
{ text: 'Retry queue with per-tenant budgets', options: { bullet: true, breakLine: true } },
{ text: 'Delivery log with replay, 30 days', options: { bullet: true } },
], { x: 0.8, y: 1.4, w: 8, h: 3, fontSize: 20, color: '3F3F46', fontFace: 'Segoe UI', paraSpaceAfter: 10 });
const num = pres.addSlide();
num.addText('Soak test, 48 hours at 3× peak', { x: 0.6, y: 0.4, w: 8.5, h: 0.7, fontSize: 30, bold: true, color: '18181B', fontFace: 'Segoe UI' });
const tiles = [['42,380', 'deliveries / s'], ['184 ms', 'p95 latency'], ['0.02%', 'errors'], ['11%', 'retry budget']];
tiles.forEach(([v, l], i) => {
const x = 0.6 + i * 2.25;
num.addShape(pres.ShapeType.roundRect, { x, y: 1.6, w: 2.05, h: 1.6, fill: { color: 'F4F4F5' }, line: { color: 'E4E4E7' }, rectRadius: 0.1 });
num.addText(v, { x, y: 1.75, w: 2.05, h: 0.8, fontSize: 28, bold: true, color: rasp, align: 'center', fontFace: 'Segoe UI' });
num.addText(l, { x, y: 2.5, w: 2.05, h: 0.5, fontSize: 13, color: '71717A', align: 'center', fontFace: 'Segoe UI' });
});
num.addText('Verdict: pass. Canary at 5% of tenants for 24 h, then general availability.', { x: 0.6, y: 3.7, w: 8.5, h: 0.6, fontSize: 16, color: '3F3F46', fontFace: 'Segoe UI' });
const plan = pres.addSlide();
plan.addText('Rollout', { x: 0.6, y: 0.4, w: 8, h: 0.7, fontSize: 30, bold: true, color: '18181B', fontFace: 'Segoe UI' });
plan.addTable([
[{ text: 'Day', options: { bold: true } }, { text: 'Milestone', options: { bold: true } }, { text: 'Owner', options: { bold: true } }],
[fmt(plus(1)), 'Go / no-go review', 'Alice'],
[fmt(plus(3)), 'Rollback runbook signed off', 'Nora'],
[fmt(plus(6)), 'Canary, 5% of tenants', 'Ahmed, Priya'],
[fmt(plus(8)), 'General availability', 'Rachel'],
], { x: 0.6, y: 1.4, w: 8.8, fontSize: 14, fontFace: 'Segoe UI', color: '3F3F46', border: { type: 'solid', color: 'E4E4E7', pt: 1 }, fill: { color: 'FFFFFF' } });
return Buffer.from(await pres.write({ outputType: 'nodebuffer' }));
}
// ── Run ──────────────────────────────────────────────────────────────────
const browser = await chromium.launch();
const ctx = await browser.newContext({ deviceScaleFactor: 2 });
const pg = await ctx.newPage();
for (const [name, html] of Object.entries(DOCS)) {
await pg.setContent(html, { waitUntil: 'load' });
write(name, await pg.pdf({ format: 'A4', printBackground: true, preferCSSPageSize: true }));
}
for (const [name, { width, height, html }] of Object.entries(IMAGES)) {
await pg.setViewportSize({ width, height });
await pg.setContent(html, { waitUntil: 'load' });
write(name, await pg.screenshot({ type: 'png', clip: { x: 0, y: 0, width, height } }));
}
await browser.close();
for (const [name, text] of Object.entries(TEXT)) write(name, Buffer.from(text, 'utf8'));
write('beacon-soak-metrics.xlsx', await xlsxLaunchMetrics());
write('budget-2026.xlsx', await xlsxBudget());
if (docx) {
write('beacon-rollback-runbook.docx', await docxRunbook());
write('service-agreement-draft.docx', await docxContract());
} else console.log('docx not available: Word files kept as they are');
if (PptxGenJS) write('beacon-launch-deck.pptx', await pptxLaunchDeck());
else console.log('pptxgenjs not available: deck kept as it is');
for (const w of written) console.log(' +', w);
console.log(`output: ${OUT}`);
+38 -19
View File
@@ -1003,6 +1003,9 @@ let refreshPromise: Promise<string | null> | null = null;
// Multi-account state: per-account JMAP clients and refresh timers
const clients = new Map<string, JMAPClient>();
/** Server URL recorded on the demo accounts; nothing is ever sent to it. */
const DEMO_SERVER_URL = 'https://demo.example.com';
/**
* Account switches can overlap (click B, then C before B lands). Each switch
* takes a generation and gives up at its next await once a newer one has
@@ -1685,30 +1688,46 @@ export const useAuthStore = create<AuthState>()(
clearAllStores();
const { DemoJMAPClient } = await import('@/lib/demo/demo-client');
const client = new DemoJMAPClient();
await client.connect();
const { seedDemoPreferences } = await import('@/lib/demo/demo-preferences');
// Two logins: the work account (primary, with the shared Support
// mailbox) and a mail-only personal account, so the account
// switcher and the merged inbox have more than one source.
const client = new DemoJMAPClient('work');
const personal = new DemoJMAPClient('personal');
await Promise.all([client.connect(), personal.connect()]);
const username = client.getUsername();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
// Register a demo account entry so the account-switcher shows
// proper avatar/name instead of a "?" placeholder.
// Register the account entries so the account switcher shows
// names and avatars, and the unified views find both clients.
const accountStore = useAccountStore.getState();
const demoAccountId = accountStore.addAccount({
label: primaryIdentity?.name || 'Demo User',
serverUrl: 'https://demo.example.com',
username,
authMode: 'basic',
rememberMe: false,
displayName: primaryIdentity?.name || 'Demo User',
email: primaryIdentity?.email || username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
isDefault: true,
});
const displayName = primaryIdentity?.name || 'Nora Lindqvist';
const register = (demoClient: InstanceType<typeof DemoJMAPClient>, label: string, isDefault: boolean) => {
const email = demoClient.getUsername();
const id = accountStore.addAccount({
label,
serverUrl: DEMO_SERVER_URL,
username: email,
authMode: 'basic',
rememberMe: false,
displayName,
email,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
isDefault,
});
// The registry is typed for the real client; the unified views only
// use the IJMAPClient surface, which the demo client implements.
clients.set(id, demoClient as unknown as JMAPClient);
return id;
};
const demoAccountId = register(client, 'Meridian Labs', true);
register(personal, 'Personal', false);
accountStore.setActiveAccount(demoAccountId);
seedDemoPreferences();
set({
isAuthenticated: true,
@@ -2563,9 +2582,9 @@ export const useAuthStore = create<AuthState>()(
const accountStore = useAccountStore.getState();
let accounts = accountStore.accounts;
// If the only account is the demo account, re-initialize demo mode
// If every account is a demo account, re-initialize demo mode
// instead of trying to restore a server session (which doesn't exist).
if (accounts.length === 1 && accounts[0].serverUrl === 'https://demo.example.com') {
if (accounts.length > 0 && accounts.every((a) => a.serverUrl === DEMO_SERVER_URL)) {
await get().loginDemo();
return;
}
+12
View File
@@ -13,6 +13,13 @@ interface PolicyState {
* full policy is fetched again once a session exists.
*/
partial: boolean;
/**
* Gates the demo switches on regardless of the deployment's policy, so the
* demo's two accounts and shared mailbox can show the merged views. Set by
* the demo login only; empty everywhere else.
*/
demoFeatureOverrides: Partial<FeatureGates>;
setDemoFeatureOverrides: (overrides: Partial<FeatureGates>) => void;
fetchPolicy: () => Promise<void>;
/** Fetch the full policy if only the public subset is loaded. */
refreshIfPartial: () => Promise<void>;
@@ -42,6 +49,9 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
policy: { ...DEFAULT_POLICY },
loaded: false,
partial: false,
demoFeatureOverrides: {},
setDemoFeatureOverrides: (overrides) => set({ demoFeatureOverrides: overrides }),
fetchPolicy: async () => {
// A fetch started before signing in may answer after the one started
@@ -86,6 +96,8 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
},
isFeatureEnabled: (feature) => {
const override = get().demoFeatureOverrides[feature];
if (override !== undefined) return override;
return get().policy.features[feature] ?? true;
},