diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 6991daabd..97b95be86 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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); }; diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index 92e17490a..ffa0eeef9 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -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(); diff --git a/lib/demo/__tests__/fixtures.test.ts b/lib/demo/__tests__/fixtures.test.ts new file mode 100644 index 000000000..796651d08 --- /dev/null +++ b/lib/demo/__tests__/fixtures.test.ts @@ -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): Set { + 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(); + const seenMessageIds = new Set(); + 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(); + 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); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index e0c13be0b..900e4e149 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -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 = { - '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 = 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(); private scheduledSubmissions = new Map(); private connectionCallback: ((connected: boolean) => void) | null = null; @@ -36,15 +42,47 @@ export class DemoJMAPClient implements IJMAPClient { private lastStates: AccountStates = {}; private incomingTimer: ReturnType | 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 { - // 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 { return []; } async setCalendarShare(): Promise { /* demo: no-op */ } @@ -134,13 +177,38 @@ export class DemoJMAPClient implements IJMAPClient { // ── Quota ───────────────────────────────────────────────────── async getQuota(): Promise { - return { used: 245_366_784, total: 1_073_741_824 }; + return { ...this.data.quota }; } // ── Mailboxes ───────────────────────────────────────────────── - async getMailboxes(_accountId?: string): Promise { return [...this.data.mailboxes]; } - async getAllMailboxes(): Promise { return [...this.data.mailboxes]; } + async getMailboxes(accountId?: string): Promise { + 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 `:` 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 { + 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 { 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 { /* no-op in demo */ } async sendImipInvitation(): Promise { /* no-op in demo */ } async sendImipCancellation(): Promise { /* no-op in demo */ } @@ -784,6 +875,9 @@ export class DemoJMAPClient implements IJMAPClient { async fetchBlob(blobId: string): Promise { 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 { return [...this.data.addressBooks]; } async getAllAddressBooks(): Promise { return [...this.data.addressBooks]; } @@ -906,7 +1000,7 @@ export class DemoJMAPClient implements IJMAPClient { async createContact(contact: Partial): Promise { 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 { return [...this.data.calendars]; } async getAllCalendars(): Promise { return [...this.data.calendars]; } @@ -990,7 +1084,7 @@ export class DemoJMAPClient implements IJMAPClient { async createCalendarEvent(event: Partial): Promise { 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): Promise { 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; + for (const [key, value] of Object.entries(updates as Record)) { + 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; + } + 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 { @@ -1047,6 +1158,7 @@ export class DemoJMAPClient implements IJMAPClient { async queryCalendarEvents(filter: CalendarEventFilter): Promise { 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[]> { - 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[]> { + 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 { 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 { + async sendRawEmail(_blob?: Blob, identityId = 'demo-identity-primary', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise { 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') } }, }); } } diff --git a/lib/demo/demo-data.ts b/lib/demo/demo-data.ts index 109050be7..876d7062f 100644 --- a/lib/demo/demo-data.ts +++ b/lib/demo/demo-data.ts @@ -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; emails: Email[]; contacts: ContactCard[]; addressBooks: AddressBook[]; @@ -26,13 +45,62 @@ export interface DemoData { sieveContent: Record; 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() }], }); } diff --git a/lib/demo/demo-preferences.ts b/lib/demo/demo-preferences.ts new file mode 100644 index 000000000..9d0ad8e29 --- /dev/null +++ b/lib/demo/demo-preferences.ts @@ -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 */ } +} diff --git a/lib/demo/demo-utils.ts b/lib/demo/demo-utils.ts index f8a15b46a..ed2bc4176 100644 --- a/lib/demo/demo-utils.ts +++ b/lib/demo/demo-utils.ts @@ -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(data: T): T { return JSON.parse(JSON.stringify(data)); diff --git a/lib/demo/fixtures/assets.ts b/lib/demo/fixtures/assets.ts new file mode 100644 index 000000000..9e4911bac --- /dev/null +++ b/lib/demo/fixtures/assets.ts @@ -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; + +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, + }; +} diff --git a/lib/demo/fixtures/calendars.ts b/lib/demo/fixtures/calendars.ts index 68f493512..f86ef298d 100644 --- a/lib/demo/fixtures/calendars.ts +++ b/lib/demo/fixtures/calendars.ts @@ -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, 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 { + // `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 { + 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'); +} diff --git a/lib/demo/fixtures/contacts.ts b/lib/demo/fixtures/contacts.ts index 145ab4b6b..805ae724f 100644 --- a/lib/demo/fixtures/contacts.ts +++ b/lib/demo/fixtures/contacts.ts @@ -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> & { + /** 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; diff --git a/lib/demo/fixtures/emails.ts b/lib/demo/fixtures/emails.ts index 84fda911b..d1325edab 100644 --- a/lib/demo/fixtures/emails.ts +++ b/lib/demo/fixtures/emails.ts @@ -1,842 +1,675 @@ +/** + * Mail in Nora's work account. One busy week around the Beacon launch: the + * team, a designer's invoice, a trip to a conference, receipts, newsletters, + * and the usual noise. See people.ts for who everyone is. + * + * Dates float with the clock: `ago()` for today, `at(daysAgo, h, m)` for + * earlier days. Body text never names a weekday directly; it uses dayName(). + */ import type { Email } from '@/lib/jmap/types'; -import { demoDate } from '../demo-utils'; +import { ago, at, authFail, authPass, dayLabel, dayName, listHeaders, mail, mid } from './mail'; +import { attachment } from './assets'; +import { MB } from './mailboxes'; +import { + addr, NORA, NORA_ALIAS, ALICE, BOB, PRIYA, AHMED, JAMES, MICHAEL, RACHEL, WEI, MARIA, LISA, + SARAH, OLIVIA, JENNIFER, KENJI, TOMAS, WORK_DOMAIN, +} from './people'; -const USER = { name: 'Demo User', email: 'demo@example.com' } as const; +const me = addr(NORA); +const alice = addr(ALICE); +const bob = addr(BOB); +const priya = addr(PRIYA); +const ahmed = addr(AHMED); +const lisa = addr(LISA); +const michael = addr(MICHAEL); +const beaconTeam = [me, alice, bob, priya, ahmed, lisa]; -// Helper to keep the fixtures short - auto-assigns a partId/blobId per body. -let bodyCounter = 0; -function body(value: string, type: 'text/plain' | 'text/html' = 'text/plain') { - const partId = String(++bodyCounter); - const blobId = `blob-${partId}`; - return { - part: { partId, blobId, size: value.length, type }, - values: { [partId]: { value } }, - }; -} +const company = authPass(WORK_DOMAIN); -/** Build text+html parts in one shot. */ -function bodies(text: string, html: string) { - const t = body(text, 'text/plain'); - const h = body(html, 'text/html'); - return { - textBody: [t.part], - htmlBody: [h.part], - bodyValues: { ...t.values, ...h.values }, - }; -} - -function textOnly(text: string) { - const t = body(text, 'text/plain'); - return { textBody: [t.part], bodyValues: t.values }; -} +/** Blob id of the go/no-go invitation; the client serves the ICS and parses it. */ +export const GONOGO_INVITE_BLOB = 'demo-blob-invite-gonogo'; +export const GONOGO_UID = 'beacon-gonogo-2026@meridianlabs.example'; export function createDemoEmails(): Email[] { return [ - // ── Inbox ─────────────────────────────────────────────────── - { - id: 'demo-email-1', - threadId: 'demo-thread-1', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 4200, - receivedAt: demoDate(0, -2), - from: [{ name: 'Bulwark Team', email: 'welcome@bulwark.email' }], - to: [USER], - subject: 'Welcome to Bulwark Mail!', - sentAt: demoDate(0, -2), - preview: 'Thanks for trying out Bulwark Mail. This is a demo environment where you can explore all features...', - hasAttachment: false, - ...bodies( - 'Thanks for trying out Bulwark Mail!\n\nThis is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.\n\nFeel free to:\n- Read, compose, and organize emails\n- Manage contacts and calendars\n- Configure filters and settings\n- Try keyboard shortcuts (press ? to see them)\n\nEnjoy exploring!', - '

Welcome to Bulwark Mail!

Thanks for trying out Bulwark Mail!

This is a demo environment where you can explore all features without connecting to a real server. All data stays on your device.

Feel free to:

  • Read, compose, and organize emails
  • Manage contacts and calendars
  • Configure filters and settings
  • Try keyboard shortcuts (press ? to see them)

Enjoy exploring!

', - ), - messageId: '', - }, + // ══ Inbox ═══════════════════════════════════════════════════ - // Mom - personal message, unread - { - id: 'demo-email-mom', - threadId: 'demo-thread-mom', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 1900, - receivedAt: demoDate(0, -4, -12), - from: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }], - to: [USER], - subject: 'when are you coming home?', - sentAt: demoDate(0, -4, -12), - preview: 'Hi sweetie, your father and I were just talking - we miss you. Any chance you can come down for a weekend...', - hasAttachment: false, - ...textOnly( - "Hi sweetie,\n\nYour father and I were just talking - we miss you. Any chance you can come down for a weekend before Christmas?\n\nNo pressure if you're swamped with work. Anna said she might be in town the 22nd, 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", - ), - messageId: '<5a8c-mom@example.com>', - }, + // A verification code, 12 minutes old: the list shows it as a chip. + mail({ + id: 'demo-email-github-code', mailbox: MB.inbox, date: ago(0, 12), + from: { name: 'GitHub', email: 'noreply@github.com' }, + subject: '482913 is your GitHub authentication code', + text: 'Here is your GitHub authentication code: 482913\n\nThis code expires in 10 minutes. If you did not try to sign in, change your password now.\n\nThanks,\nThe GitHub Team', + headers: authPass('github.com'), + }), - // GitHub - PR review request - { - id: 'demo-email-gh-pr', - threadId: 'demo-thread-gh-pr', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 6400, - receivedAt: demoDate(0, -3, -5), - from: [{ name: 'Alice Johnson (via GitHub)', email: 'notifications@github.com' }], - replyTo: [{ name: 'reply', email: 'reply+abc123@reply.github.com' }], - to: [USER], - subject: '[acme/api-gateway] Add token-bucket rate limiter (#1284)', - sentAt: demoDate(0, -3, -5), - preview: '@demo-user requested your review on this pull request. Replaces the fixed-window limiter with a leaky token-bucket...', - hasAttachment: false, - ...bodies( - '@demo-user requested your review on this pull request.\n\nReplaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.\n\nThree files changed, +312 −47.\n\nView it on GitHub:\nhttps://github.com/acme/api-gateway/pull/1284\n\n-\nReply to this email directly, or view it on GitHub.', - '
@demo-user requested your review on this pull request.
Replaces the fixed-window limiter with a leaky token-bucket so we stop punishing clients at the second-boundary edge. Per-endpoint config lives in rate-limit.toml.
Three files changed, +312 −47
View on GitHub
', - ), - messageId: '', - }, + // Bob: short, unread, a reply in the rollback thread (3 messages). + mail({ + id: 'demo-email-rollback-3', thread: 'demo-thread-rollback', mailbox: MB.inbox, date: ago(0, 38), + from: bob, to: [me], cc: [alice], + subject: 'Re: Rollback plan for the Beacon launch', + text: `+1 on the draft, two comments:\n\n1. Step 3 (drain the queue) should say who watches the dashboard while it drains. I'd put Ahmed's name there.\n2. The signing-key question at the end: I asked the auditor, we keep rotating during a rollback. Nothing to change.\n\nI'll sign off once those are in. Nice work, it's half the length I feared.\n\nBob`, + keywords: ['$label:beacon'], + headers: company, + inReplyTo: [mid('demo-email-rollback-2')], references: [mid('demo-email-rollback-1'), mid('demo-email-rollback-2')], + }), - // Hacker Newsletter - newsletter, read - { - id: 'demo-email-2', - threadId: 'demo-thread-2', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 18500, - receivedAt: demoDate(-1, -5), - from: [{ name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }], - to: [USER], - subject: 'Issue #218 - RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla', - sentAt: demoDate(-1, -5), - preview: 'Your weekly roundup of the most important technology news and open source developments...', - hasAttachment: false, - ...bodies( - 'TechDigest #218\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is *almost* in but punted to a separate spec, which feels like the right call.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system. Worth reading the post.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, Tigris-style, written in Go.\n- Linear added an SSO migration tool that actually handles the IdP-initiated case.\n\n- ESSAYS -\n\n* "Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.\n* "I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.\n\n- UNSUBSCRIBE -\n\nManage your subscription at techdigest.example/manage.', - '
TechDigest · Issue #218

RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla

The week in standards

1. RFC 9844: Per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large - Mike Crispin has a write-up that runs through what changes for transactional senders.

2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. Component model is almost in but punted to a separate spec, which feels like the right call.

3. Mozilla quietly shipped a privacy-preserving telemetry channel to Firefox 132. No, it doesn\'t replace ad tracking. Yes, it\'s a real cryptographic system.

Tools

  • Datasette 1.0 is out. Ten years from the first commit.
  • Fly.io published their object store, Tigris-style, written in Go.
  • Linear added an SSO migration tool that actually handles the IdP-initiated case.

Essays

"Postgres is enough" by E. Tan - a long-form rebuttal to the microservices-by-default pattern.

"I rewrote my home network in TypeScript so you don\'t have to" - exactly what it sounds like.

Manage your subscription at techdigest.example/manage
', - ), - messageId: '', - }, + // Alice opened the thread, mentioning Nora and Bob with @ links. The + // viewer turns those into contact chips. + mail({ + id: 'demo-email-rollback-1', thread: 'demo-thread-rollback', mailbox: MB.inbox, date: at(1, 10, 2), + from: alice, to: [me, bob], cc: [ahmed], + subject: 'Rollback plan for the Beacon launch', + text: `Hi both,\n\nMichael wants a written rollback plan before the go/no-go, one page, no slides. @Nora can you own it? You know the tenant router best. @Bob please review for anything the auditor would flag.\n\nWhat it needs: when we roll back (numbers, not feelings), the steps, who does what, and what customers see. Ahmed has the dashboards ready if you need numbers.\n\nEnd of ${dayName(1)} would be ideal so it can go out with the agenda.\n\nThanks,\nAlice`, + html: `

Hi both,

Michael wants a written rollback plan before the go/no-go, one page, no slides. @Nora can you own it? You know the tenant router best. @Bob please review for anything the auditor would flag.

What it needs: when we roll back (numbers, not feelings), the steps, who does what, and what customers see. Ahmed has the dashboards ready if you need numbers.

End of ${dayName(1)} would be ideal so it can go out with the agenda.

Thanks,
Alice

`, + keywords: ['$seen', '$label:beacon'], + headers: company, + }), - // Thread: Q4 Project Timeline - Alice → Bob → Alice (4 messages) - { - id: 'demo-email-3a', - threadId: 'demo-thread-3', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 3100, - receivedAt: demoDate(-3, -10), - from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], - to: [USER, { name: 'Bob Chen', email: 'bob.chen@example.com' }], - subject: 'Q4 Project Timeline', - sentAt: demoDate(-3, -10), - preview: 'Hi team, I wanted to share the updated timeline for our Q4 deliverables...', - hasAttachment: false, - ...bodies( - 'Hi team,\n\nI wanted to share the updated timeline for our Q4 deliverables:\n\n- Phase 1: Design review - Oct 15\n- Phase 2: Development - Nov 1-30\n- Phase 3: Testing - Dec 1-15\n- Phase 4: Launch - Dec 20\n\nPlease review and let me know if you see any conflicts.\n\nBest,\nAlice', - '

Hi team,

I wanted to share the updated timeline for our Q4 deliverables:

  • Phase 1: Design review - Oct 15
  • Phase 2: Development - Nov 1-30
  • Phase 3: Testing - Dec 1-15
  • Phase 4: Launch - Dec 20

Please review and let me know if you see any conflicts.

Best,
Alice

', - ), - messageId: '', - }, - { - id: 'demo-email-3b', - threadId: 'demo-thread-3', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 3500, - receivedAt: demoDate(-2, -8), - from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }], - to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }, USER], - subject: 'Re: Q4 Project Timeline', - sentAt: demoDate(-2, -8), - preview: 'Looks good to me! One concern: the testing window might be tight given the holidays...', - hasAttachment: false, - ...textOnly( - "Looks good to me! One concern: the testing window might be tight given the holidays. Could we start testing a few days earlier?\n\nAlso, should we set up a shared doc for tracking blockers?\n\n- Bob", - ), - messageId: '', - inReplyTo: [''], - references: [''], - }, - { - id: 'demo-email-3c', - threadId: 'demo-thread-3', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 3800, - receivedAt: demoDate(-1, -3), - from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], - to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }, USER], - subject: 'Re: Q4 Project Timeline', - sentAt: demoDate(-1, -3), - preview: "Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today...", - hasAttachment: false, - ...textOnly( - "Great point Bob. Let's move testing to Nov 28. I'll create the shared doc today and share the link.\n\nUpdated timeline:\n- Design review: Oct 15\n- Development: Nov 1-27\n- Testing: Nov 28 - Dec 15\n- Launch: Dec 20\n\n- Alice", - ), - messageId: '', - inReplyTo: [''], - references: ['', ''], - }, + // The go/no-go invitation. The .ics is served by the client; the banner + // offers Accept / Tentative / Decline. + mail({ + id: 'demo-email-gonogo-invite', mailbox: MB.inbox, date: at(1, 9, 48), + from: alice, to: beaconTeam, cc: [michael], + subject: `Invitation: Beacon go / no-go review, ${dayName(1)} 14:00`, + text: `You are invited to "Beacon go / no-go review".\n\nWhen: ${dayName(1)}, ${dayLabel(1)}, 14:00 to 15:00 (Europe/Berlin)\nWhere: Room Spree, 4th floor, and Zoom\n\nAgenda:\n1. Soak test results (Ahmed)\n2. Rollback plan (Nora)\n3. Open bugs, BEA-412 in particular (Priya)\n4. Decision\n\nPlease respond so I know who is in the room and who dials in.`, + attachments: [{ partId: 'ics-gonogo', blobId: GONOGO_INVITE_BLOB, size: 1480, name: 'invite.ics', type: 'text/calendar; method=REQUEST' }], + keywords: ['$label:beacon'], + headers: company, + }), - // Stripe receipt - { - id: 'demo-email-stripe', - threadId: 'demo-thread-stripe', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 11200, - receivedAt: demoDate(-1, -1, -22), - from: [{ name: 'Stripe', email: 'receipts@stripe.com' }], - to: [USER], + mail({ + id: 'demo-email-coffee', mailbox: MB.inbox, date: ago(1, 2), + from: bob, + subject: 'Coffee before the go/no-go?', + text: `Kranzler at 13:15 ${dayName(1)}? I want to walk through the queue-drain step once more without six people watching.\n\nb`, + headers: company, + }), + + mail({ + id: 'demo-email-linear', mailbox: MB.inbox, date: ago(2, 3), + from: { name: 'Linear', email: 'notifications@linear.app' }, + subject: 'BEA-412 was assigned to you - Webhook retries double delivery volume during a partial outage', + text: 'Priya Sharma assigned BEA-412 to you.\n\nBEA-412 · Webhook retries double delivery volume during a partial outage\nPriority: High · Project: Beacon · Cycle 41\n\nWhen eu-2 is unreachable for more than 30 s, deliveries that already succeeded on eu-1 are retried from eu-2 once it is back. Customers see every webhook twice. The per-tenant budget caps it at 2x, which is the documented worst case, but the dedup key is not honoured across regions.\n\nOpen in Linear: https://linear.app/meridian/issue/BEA-412', + html: '
Linear · BEA-412
Webhook retries double delivery volume during a partial outage
Priya Sharma assigned this issue to you · Priority High · Cycle 41
When eu-2 is unreachable for more than 30 s, deliveries that already succeeded on eu-1 are retried from eu-2 once it is back. Customers see every webhook twice. The per-tenant budget caps it at 2x, which is the documented worst case, but the dedup key is not honoured across regions.
Open in Linear
', + keywords: ['$label:beacon'], + headers: authPass('linear.app'), + }), + + mail({ + id: 'demo-email-github-pr', mailbox: MB.inbox, date: ago(2, 50), + from: { name: 'Lisa Tanaka (via GitHub)', email: 'notifications@github.com' }, + replyTo: [{ name: 'meridianlabs/beacon', email: 'reply+AAZK3@reply.github.com' }], + subject: '[meridianlabs/beacon] Signed webhook deliveries: rotate keys without a gap (#1284)', + text: '@nora-lindqvist requested your review on this pull request.\n\nKeeps the previous signing key valid for 48 h after a rotation and adds the key id to the X-Beacon-Signature header, so receivers can verify during the overlap. Closes BEA-398.\n\n7 files changed, +412 -58.\n\nView it on GitHub:\nhttps://github.com/meridianlabs/beacon/pull/1284', + html: '
@lisa-tanaka requested your review on this pull request.
Keeps the previous signing key valid for 48 h after a rotation and adds the key id to the X-Beacon-Signature header, so receivers can verify during the overlap. Closes BEA-398.
7 files changed, +412 −58
View on GitHub
', + keywords: ['$label:beacon'], + headers: { ...authPass('github.com'), 'List-Id': 'meridianlabs/beacon ' }, + }), + + mail({ + id: 'demo-email-datadog', mailbox: MB.inbox, date: at(1, 3, 12), + from: { name: 'Datadog', email: 'alert@dtdg.co' }, + subject: '[Recovered] beacon-eu-2 p95 delivery latency above 400 ms', + text: 'Monitor: beacon-eu-2 p95 delivery latency\nStatus: Recovered after 4 minutes\n\nPeak: 612 ms at 03:07, back under 200 ms at 03:11. The alert fired during the nightly key rotation; the overlap window in #1284 should remove the spike.\n\nNotified: @nora, @ahmed', + keywords: ['$seen', '$label:beacon'], + headers: authPass('dtdg.co'), + }), + + // Sarah's invoice and prototype, the message the docs open in the inbox shot. + mail({ + id: 'demo-email-sarah-invoice', mailbox: MB.inbox, date: at(1, 16, 5), + from: addr(SARAH), + subject: 'Invoice 2026-089 and landing-page prototype v3', + text: `Hi Nora,\n\nAttached are the invoice for this month and a screenshot of the latest prototype. I went with option B for the hero (the asymmetric grid) since you said the symmetrical version felt too flat on our last call.\n\nIf the line items look off, ping me. I backed out the pre-payment for next month as agreed.\n\nBest,\nSarah`, + attachments: [attachment('demo-blob-invoice-designco'), attachment('demo-blob-prototype')], + keywords: ['$label:work', '$label:finance'], + headers: authPass('designco.example'), + }), + + mail({ + id: 'demo-email-lisa-longpoll', mailbox: MB.inbox, date: at(1, 17, 30), + from: lisa, to: [me], cc: [bob], + subject: 'Long-poll reconnect test: one weird thing', + text: `The staging tenant reconnected fine at cutover, once, as planned. But three clients reconnected twice, about 90 s apart. Not harmful, they just see one empty response.\n\nI think it's the load balancer's idle timeout racing the first reconnect. I can extend the timeout on the LB or teach the client to back off. Preference? I'd rather not touch the client this close to launch.\n\nLisa`, + keywords: ['$label:beacon'], + headers: company, + }), + + // Ahmed, with the dashboard screenshot and the numbers. + mail({ + id: 'demo-email-soak', mailbox: MB.inbox, date: at(1, 18, 42), + from: ahmed, to: beaconTeam, + subject: 'Soak test passed: 48 h at 3x peak', + text: 'Done. 48 hours at three times peak traffic on eu-1 and eu-2, no retry-budget breach, no manual intervention.\n\nDeliveries/s: 42,380 peak\np95 delivery latency: 184 ms\nErrors: 0.02%\nRetry budget used: 11% (worst tenant)\n\nDashboard screenshot and the hourly numbers attached. The dip around hour 31 is the key rotation, see BEA-398.\n\nI\'d call it a pass. Priya wants one more night at 1x on the canary tenants, which I agree with.\n\nAhmed', + attachments: [attachment('demo-blob-soak-dashboard'), attachment('demo-blob-soak-metrics')], + keywords: ['$seen', '$flagged', '$label:beacon'], + headers: company, + }), + + mail({ + id: 'demo-email-rachel-quote', mailbox: MB.inbox, date: at(1, 13, 20), + from: addr(RACHEL), + subject: 'Launch announcement draft: need a quote from you', + text: `Hi Nora,\n\nThe blog post is drafted (link in Notion). I need one quote from engineering, two sentences, why Beacon matters to someone running webhooks today. Not "we are excited".\n\nCan I have it by ${dayName(3)}? Rachel`, + keywords: ['$label:work', '$label:follow-up'], + headers: company, + }), + + mail({ + id: 'demo-email-vercel', mailbox: MB.inbox, date: at(1, 22, 15), + from: { name: 'Vercel', email: 'notifications@vercel.com' }, + subject: 'Deployment failed: meridianlabs-website (main)', + text: 'Your deployment of meridianlabs-website failed.\n\nBranch: main\nCommit: 7c21f3e "hero: switch to option B grid"\nError: Image optimisation failed for /hero/prototype-v3.png (file too large)\n\nView the build logs: https://vercel.com/meridianlabs/website/deployments/dpl_9x2', + keywords: ['$label:work'], + headers: authPass('vercel.com'), + }), + + mail({ + id: 'demo-email-notion', mailbox: MB.inbox, date: at(1, 10, 5), + from: { name: 'Olivia Bennett (via Notion)', email: 'notify@mail.notion.so' }, + subject: 'Olivia shared "Beacon onboarding flow, v2" with you', + text: 'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Beacon onboarding flow, v2"\n\nOpen in Notion: https://notion.so/northwind/beacon-onboarding-v2', + keywords: ['$seen', '$label:work'], + headers: authPass('mail.notion.so'), + }), + + mail({ + id: 'demo-email-techdigest', mailbox: MB.inbox, date: at(1, 6, 30), + from: { name: 'TechDigest Weekly', email: 'newsletter@techdigest.example' }, + subject: 'Issue #231: RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla', + text: 'TechDigest #231\n\n- THE WEEK IN STANDARDS -\n\n1. RFC 9844: per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large.\n\n2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. The component model is almost in but punted to a separate spec.\n\n3. Mozilla quietly shipped a privacy-preserving telemetry channel. No, it does not replace ad tracking. Yes, it is a real cryptographic system.\n\n- TOOLS -\n\n- Datasette 1.0 is out. Ten years from the first commit.\n- Fly.io published their object store, written in Go.\n\n- ESSAYS -\n\n* "Postgres is enough": a long-form rebuttal to microservices by default.\n\nManage your subscription at techdigest.example/manage.', + html: '
TechDigest · Issue #231

RFC 9844, the second WebAssembly draft, and a quiet announcement from Mozilla

The week in standards

1. RFC 9844: per-message TLS extensions are now official. The implications for SMTP delivery reports are surprisingly large.

2. WebAssembly 2.0 (second public draft). Tail calls are in. SIMD is in. The component model is almost in but punted to a separate spec.

3. Mozilla quietly shipped a privacy-preserving telemetry channel. No, it does not replace ad tracking. Yes, it is a real cryptographic system.

Tools

  • Datasette 1.0 is out. Ten years from the first commit.
  • Fly.io published their object store, written in Go.

Essays

"Postgres is enough": a long-form rebuttal to microservices by default.

Manage your subscription at techdigest.example/manage
', + keywords: ['$seen'], + headers: { ...authPass('techdigest.example'), ...listHeaders('TechDigest Weekly', 'techdigest.example') }, + }), + + // Michael asks for a read receipt; the viewer offers to send one. + mail({ + id: 'demo-email-launch-plan', mailbox: MB.inbox, date: at(2, 9, 12), + from: michael, to: beaconTeam, cc: [addr(JAMES), addr(RACHEL)], + subject: 'Beacon launch plan v3 (please read before the go/no-go)', + text: `Team,\n\nv3 of the launch plan is attached. Changes from v2: the canary runs 24 h instead of 12, the rollback runbook is a gate (Nora), and GA moves to ${dayLabel(8)} so the blog post and the changelog land on the same day.\n\nPlease read it before the review. If you disagree with a date, say so in the thread, not in the meeting.\n\nMichael`, + attachments: [attachment('demo-blob-launch-plan')], + keywords: ['$seen', '$label:beacon'], + headers: { ...company, 'Disposition-Notification-To': `Michael Torres <${MICHAEL.email}>` }, + }), + + // Priya and Nora, a thread that spans Inbox and Sent. + mail({ + id: 'demo-email-canary-3', thread: 'demo-thread-canary', mailbox: MB.inbox, date: at(1, 8, 55), + from: priya, to: [me], cc: [ahmed], + subject: 'Re: Canary length: 12 h or 24 h?', + text: 'Agreed, 24 h and we pick the five tenants together on the day. I\'ll add the overnight check to the QA plan.\n\nOne more: I want BEA-412 fixed or explicitly accepted before the canary, not during. Can you raise it at the review?\n\nPriya', + keywords: ['$seen', '$label:beacon'], + headers: company, + inReplyTo: [mid('demo-email-canary-2')], references: [mid('demo-email-canary-1'), mid('demo-email-canary-2')], + }), + mail({ + id: 'demo-email-canary-1', thread: 'demo-thread-canary', mailbox: MB.inbox, date: at(2, 14, 30), + from: priya, to: [me], cc: [ahmed], + subject: 'Canary length: 12 h or 24 h?', + text: 'The plan says 12 h for the canary. That gives us one business-hours window and no night. Most of the retry weirdness we saw in the soak was at night, when the batch customers run.\n\nI\'d like 24 h. It costs one day on the schedule. Thoughts before I put it to Michael?\n\nPriya', + keywords: ['$seen', '$label:beacon'], + headers: company, + }), + + mail({ + id: 'demo-email-docusign', mailbox: MB.inbox, date: at(2, 11, 25), + from: { name: 'Michael Torres via DocuSign', email: 'dse@docusign.net' }, + subject: 'Please review and sign: Lumen Logistics service agreement', + text: 'Michael Torres sent you a document to review and sign.\n\nLumen Logistics service agreement (Business plan, Beacon add-on)\n\nReview document: https://app.docusign.com/signing/9f3a2c\n\nThis is a demo of a signing request; the link does not go anywhere.', + keywords: ['$label:work', '$label:follow-up'], + headers: authPass('docusign.net'), + }), + + mail({ + id: 'demo-email-tomas-po', mailbox: MB.inbox, date: at(4, 9, 50), + from: addr(TOMAS), to: [me], cc: [michael], + subject: 'Re: Beacon add-on for Lumen Logistics', + text: 'Hi Nora,\n\nThank you for the walkthrough last week. Finance approved: the purchase order is attached, Business plan plus the Beacon add-on for fifty tenants. We would like to start on the canary if you will have us; our Lisbon team is used to being first.\n\nTomás\n\nTomás Ferreira · Head of Operations · Lumen Logistics S.A.', + attachments: [attachment('demo-blob-po-lumen')], + keywords: ['$seen', '$label:work', '$label:finance'], + headers: authPass('lumenlogistics.example'), + }), + + mail({ + id: 'demo-email-kenji', mailbox: MB.inbox, date: at(2, 16, 40), + from: addr(KENJI), + subject: 'Speaker logistics: your slot, the AV check and the dinner', + text: `Dear Nora,\n\nThank you again for speaking at Infra Summit. The details:\n\nYour talk: "Rolling back without rolling over", ${dayName(11)} 11:30, Auditorium 2, 30 min plus 10 for questions.\nAV check: ${dayName(11)} 08:45 at the auditorium. Bring your own adapter if you need USB-C to HDMI, though we have a few.\nSpeakers' dinner: ${dayName(10)} 20:00 at Cervejaria Ramiro, five minutes from the venue.\n\nYour ticket is attached; it also covers the workshop day.\n\nSee you in Lisbon,\nKenji\n\nKenji Watanabe · Programme chair, Infra Summit`, + attachments: [attachment('demo-blob-summit-ticket')], + keywords: ['$seen', '$label:travel'], + headers: authPass('infrasummit.example'), + }), + + mail({ + id: 'demo-email-james', mailbox: MB.inbox, date: at(2, 19, 5), + from: addr(JAMES), + subject: 'Re: Board update', + text: 'Nora, this was the clearest engineering update the board has had. Two questions came back, both about the migration tooling for long-poll customers, which tells me the rest landed. Thank you.\n\nJ', + keywords: ['$seen', '$label:work'], + headers: company, + inReplyTo: [mid('demo-email-sent-board')], references: [mid('demo-email-sent-board')], + }), + + mail({ + id: 'demo-email-sentry', mailbox: MB.inbox, date: at(2, 22, 40), + from: { name: 'Sentry', email: 'noreply@md.getsentry.com' }, + subject: '[beacon-api] New issue: TypeError in WebhookSigner.sign (key id undefined)', + text: 'A new issue was seen in beacon-api (production).\n\nTypeError: Cannot read properties of undefined (reading \'id\')\n at WebhookSigner.sign (signer.ts:84)\n at deliver (delivery.ts:211)\n\n3 events · 1 user · first seen 22:38\n\nView on Sentry: https://sentry.io/organizations/meridian/issues/51822', + keywords: ['$seen', '$label:beacon'], + headers: authPass('md.getsentry.com'), + }), + + mail({ + id: 'demo-email-lufthansa', mailbox: MB.inbox, date: at(3, 12, 15), + from: { name: 'Lufthansa', email: 'noreply@lufthansa.com' }, + subject: 'Your booking E7KQ2R: Berlin to Lisbon', + text: `Thank you for your booking.\n\nLH 1796 · ${dayLabel(9)} · Berlin (BER) 08:35 to Lisbon (LIS) 11:20\nLH 1797 · ${dayLabel(12)} · Lisbon (LIS) 12:10 to Berlin (BER) 16:50\n\nPassenger: Nora Lindqvist · Seat 14A / 11F · Economy\nBooking reference: E7KQ2R\n\nYour e-ticket is attached. Online check-in opens 23 hours before departure.`, + attachments: [attachment('demo-blob-boarding-pass', { name: 'E-ticket-E7KQ2R.pdf' })], + keywords: ['$seen', '$label:travel'], + headers: authPass('lufthansa.com'), + }), + + // Booking.com: an HTML mail with a remote image, so the viewer asks before loading it. + mail({ + id: 'demo-email-booking', mailbox: MB.inbox, date: at(3, 12, 40), + from: { name: 'Booking.com', email: 'noreply@booking.com' }, + subject: 'Confirmation 4892.771.433: Hotel Tejo Vista, Lisbon', + text: `Your booking is confirmed.\n\nHotel Tejo Vista, Rua do Alecrim 48, Lisbon\nCheck-in: ${dayLabel(9)}, from 15:00\nCheck-out: ${dayLabel(12)}, until 11:00\n\nSuperior double, river view, breakfast included\nTotal: EUR 486 (paid)\n\nConfirmation number: 4892.771.433 · PIN 2047\n\nYour voucher is attached. Show it at reception.`, + html: `
Hotel Tejo Vista

Your booking is confirmed

Hotel Tejo Vista, Rua do Alecrim 48, Lisbon

Check-in${dayLabel(9)}, from 15:00
Check-out${dayLabel(12)}, until 11:00
RoomSuperior double, river view
TotalEUR 486 (paid)

Confirmation number 4892.771.433 · PIN 2047. Your voucher is attached.

`, + attachments: [attachment('demo-blob-hotel-voucher')], + keywords: ['$seen', '$label:travel'], + headers: authPass('booking.com'), + }), + + mail({ + id: 'demo-email-jennifer', mailbox: MB.inbox, date: at(3, 10, 10), + from: addr(JENNIFER), + subject: 'Head of Platform, Series B, Berlin or remote', + text: 'Hi Nora,\n\nI came across your talk abstract for Infra Summit and thought of a client of mine: a Series B in real-time data infrastructure, 60 engineers, Berlin office with a remote-first culture. They are hiring a Head of Platform.\n\nStack is Rust and Postgres with a fair amount of Go. Compensation is in the range you would expect at that stage, with meaningful equity.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners', + headers: authPass('talent-partners.example'), + }), + + mail({ + id: 'demo-email-maria', mailbox: MB.inbox, date: at(3, 9, 0), + from: addr(MARIA), to: [{ name: 'All staff', email: `all@${WORK_DOMAIN}` }], + subject: 'Reminder: Q4 development conversations', + text: `Hi everyone,\n\nA reminder that Q4 development conversations run until the end of the month. Managers, please book the 45 minutes with each report by ${dayLabel(10)} and use the new template in Notion (it is shorter, you will like it).\n\nQuestions to me, as always.\n\nMaria\nPeople Ops`, + keywords: ['$seen'], + headers: company, + }), + + mail({ + id: 'demo-email-ahmed-status', mailbox: MB.inbox, date: at(3, 16, 5), + from: ahmed, to: [me, lisa], + subject: 'Status page component for Beacon is on staging', + text: 'https://status-staging.meridianlabs.example/beacon\n\nShows delivery success rate and p95 per region, 90 days. Lisa, the colours come from the website tokens so it should match the redesign. Tell me if the legend reads wrong on dark.\n\nA', + keywords: ['$seen', '$label:beacon'], + headers: company, + }), + + mail({ + id: 'demo-email-wei', mailbox: MB.inbox, date: at(4, 15, 20), + from: addr(WEI), to: [me, michael], + subject: 'Beacon adoption model: early numbers', + text: 'Quick one before the plan is final. Based on the beta cohort, 38% of tenants enable webhooks in the first week and 71% within a month. Long-poll usage drops to under 10% of requests by week six. So the migration window of a year is generous; six months would do.\n\nFull model in the Beacon folder. Wei', + keywords: ['$seen', '$label:beacon'], + headers: company, + }), + + mail({ + id: 'demo-email-figma', mailbox: MB.inbox, date: at(5, 10, 30), + from: { name: 'Figma', email: 'no-reply@figma.com' }, + subject: 'Sarah Kim invited you to "Meridian website redesign"', + text: 'Sarah Kim (sarah@designco.example) invited you to edit the file "Meridian website redesign".\n\nOpen in Figma: https://www.figma.com/file/demo/meridian-website-redesign', + keywords: ['$seen', '$label:work'], + headers: authPass('figma.com'), + }), + + mail({ + id: 'demo-email-aws', mailbox: MB.inbox, date: at(5, 8, 15), + from: { name: 'Amazon Web Services', email: 'no-reply-aws@amazon.com' }, + subject: 'Your AWS invoice is available: USD 1,284.17', + text: 'Your invoice for the previous billing period is now available.\n\nTotal this period: USD 1,284.17 (up USD 96.40, Beacon soak test)\n\nTop services:\n Amazon EC2 USD 612.08\n Amazon S3 USD 288.94\n Amazon CloudFront USD 141.50\n Amazon Route 53 USD 64.00\n Other USD 177.65\n\nView the full invoice in the Billing console.', + keywords: ['$seen', '$label:finance'], + headers: authPass('amazon.com'), + }), + + mail({ + id: 'demo-email-1password', mailbox: MB.inbox, date: at(2, 7, 45), + from: { name: '1Password', email: 'support@1password.com' }, + subject: 'Your 1Password Business subscription renews in 7 days', + text: 'Hi Nora,\n\nYour 1Password Business subscription for Meridian Labs (24 seats) renews automatically in 7 days. The card on file ends in 4242.\n\nNothing to do unless you want to change the plan or the payment method.\n\nThe 1Password team', + keywords: ['$seen', '$label:finance'], + headers: authPass('1password.com'), + }), + + mail({ + id: 'demo-email-linkedin', mailbox: MB.inbox, date: at(4, 11, 0), + from: { name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }, + subject: '5 jobs matching "staff engineer · remote · eu", including one at Datadog', + text: 'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer, Datadog (Remote, EU)\n2. Principal Engineer, Platform, Sentry (Remote, EU)\n3. Staff Backend Engineer, Linear (Remote)\n4. Tech Lead, Infrastructure, Tailscale (Remote, EU)\n5. Staff Engineer, Mobile, Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.', + keywords: ['$seen'], + headers: { ...authPass('linkedin.com'), ...listHeaders('LinkedIn Job Alerts', 'linkedin.com') }, + }), + + mail({ + id: 'demo-email-github-key', mailbox: MB.inbox, date: at(4, 20, 12), + from: { name: 'GitHub', email: 'noreply@github.com' }, + subject: '[GitHub] A new SSH key was added to your account', + text: 'Hey nora-lindqvist,\n\nA new public key was added to your account:\n\n nora@framework-16 (ED25519) SHA256:k9Qz...\n\nIf you believe this key was added in error, you can remove it in your SSH keys settings.\n\nThanks,\nThe GitHub Team', + keywords: ['$seen'], + headers: authPass('github.com'), + }), + + mail({ + id: 'demo-email-dependabot', mailbox: MB.inbox, date: at(5, 6, 0), + from: { name: 'dependabot[bot]', email: 'notifications@github.com' }, + subject: '[meridianlabs/beacon] Bump jsonwebtoken from 9.0.0 to 9.0.2 in /beacon-api (#1279)', + text: 'Bumps jsonwebtoken from 9.0.0 to 9.0.2.\n\nRelease notes: fixes a verification bypass when the algorithm is not pinned (GHSA-8cf7-32gw-wr33).\n\nDependabot will resolve any conflicts with this PR as long as you don\'t alter it yourself.', + keywords: ['$seen', '$label:beacon'], + headers: { ...authPass('github.com'), 'List-Id': 'meridianlabs/beacon ' }, + }), + + mail({ + id: 'demo-email-olivia-workshop', mailbox: MB.inbox, date: at(6, 14, 0), + from: addr(OLIVIA), + subject: 'Workshop at Northwind: two slots left', + text: `Hi Nora,\n\nWe are running the onboarding workshop for Beacon design partners on ${dayLabel(13)}, two slots left. I kept one for Meridian in case you or Lisa want to sit in and hear what the first customers stumble over.\n\nSay the word and I'll put you down.\n\nOlivia`, + keywords: ['$seen', '$label:work'], + headers: authPass('northwind.example'), + }), + + mail({ + id: 'demo-email-hetzner-maint', mailbox: MB.inbox, date: at(6, 9, 30), + from: { name: 'Hetzner Cloud', email: 'noreply@hetzner.com' }, + subject: `Scheduled maintenance: Falkenstein network, ${dayLabel(5)} 02:00 to 04:00 CEST`, + text: `Dear customer,\n\nWe will perform network maintenance in our Falkenstein data centre on ${dayLabel(5)} between 02:00 and 04:00 CEST. Affected: the load balancer lb-beacon-eu-1. Expect up to two minutes of packet loss.\n\nNo action is required on your side.\n\nHetzner Cloud`, + keywords: ['$seen', '$label:beacon'], + headers: authPass('hetzner.com'), + }), + + // ══ Sent ═══════════════════════════════════════════════════ + mail({ + id: 'demo-email-rollback-2', thread: 'demo-thread-rollback', mailbox: MB.sent, date: at(1, 16, 20), + from: me, to: [alice, bob], cc: [ahmed], + subject: 'Re: Rollback plan for the Beacon launch', + text: 'Draft attached, one page plus a table. The short version: we roll back on numbers (error rate above 0.5% for ten minutes, p95 above 1 s, or a retry-budget breach on three tenants), the on-call decides alone, and the whole thing is five steps.\n\nBob, the open question at the end is for you: do we keep rotating signing keys during a rollback window?\n\nNora', + attachments: [attachment('demo-blob-runbook')], + keywords: ['$seen', '$label:beacon'], + inReplyTo: [mid('demo-email-rollback-1')], references: [mid('demo-email-rollback-1')], + }), + mail({ + id: 'demo-email-canary-2', thread: 'demo-thread-canary', mailbox: MB.sent, date: at(2, 15, 10), + from: me, to: [priya], cc: [ahmed], + subject: 'Re: Canary length: 12 h or 24 h?', + text: '24 h. The night is where the batch customers live and that is exactly who we are nervous about. I\'ll tell Michael it costs a day and why; he will take it.\n\nCan we pick the five canary tenants together? I want Lumen in there, they asked to be first.\n\nNora', + keywords: ['$seen', '$label:beacon'], + inReplyTo: [mid('demo-email-canary-1')], references: [mid('demo-email-canary-1')], + }), + mail({ + id: 'demo-email-sent-board', mailbox: MB.sent, date: at(2, 18, 30), + from: me, to: [addr(JAMES), michael], + subject: 'Board update', + text: 'James, Michael,\n\nThe quarterly report for the board pack is attached. Engineering section is two pages: uptime, latency, Beacon status, and what next quarter costs. I kept the migration tooling as an open item rather than promising a date.\n\nShout if anything needs softening before it goes out.\n\nNora', + attachments: [attachment('demo-blob-quarterly-report')], + keywords: ['$seen', '$label:work'], + }), + mail({ + id: 'demo-email-sent-kenji', mailbox: MB.sent, date: at(2, 17, 55), + from: me, to: [addr(KENJI)], + subject: 'Re: Speaker logistics: your slot, the AV check and the dinner', + text: 'Dear Kenji,\n\nAll noted, thank you. I\'ll be at the AV check at 08:45 and I would love to join the dinner. One request: could my slides go on the venue machine as well? I have had a laptop die on stage once and would rather not repeat it.\n\nSee you in Lisbon,\nNora', + keywords: ['$seen', '$label:travel'], + inReplyTo: [mid('demo-email-kenji', 'infrasummit.example')], references: [mid('demo-email-kenji', 'infrasummit.example')], + }), + mail({ + id: 'demo-email-sent-sarah', mailbox: MB.sent, date: at(5, 11, 40), + from: me, to: [addr(SARAH)], + subject: 'Re: Prototype v2: hero options', + text: 'Hi Sarah,\n\nOption B. The symmetrical one is calmer but it reads like every other developer-tool site this year. Go with the asymmetric grid, and let\'s see v3 with the real copy from Rachel.\n\nInvoice whenever you are ready; the pre-payment for next month can wait until the launch is out.\n\nThanks,\nNora', + keywords: ['$seen', '$label:work'], + }), + + // ══ Drafts ═════════════════════════════════════════════════ + mail({ + id: 'demo-email-draft-jennifer', mailbox: MB.drafts, date: at(1, 12, 10), + from: me, to: [addr(JENNIFER)], + subject: 'Re: Head of Platform, Series B, Berlin or remote', + text: 'Hi Jennifer,\n\nThanks for reaching out. I am not looking right now; we are two weeks from a launch and I like where this is going. That said, I\'d be curious what "meaningful equity" means at Series B these days, so if you want to send the ', + keywords: ['$seen', '$draft'], + }), + mail({ + id: 'demo-email-draft-lumen', mailbox: MB.drafts, date: at(1, 15, 45), + from: me, to: [addr(TOMAS)], cc: [michael], + subject: 'Lumen Logistics: kickoff and the canary', + text: 'Hi Tomás,\n\nThank you for the PO, and yes: Lumen is on the canary list. Attached is the draft service agreement for your legal team to look at before DocuSign goes out; the SLA section is the one worth reading.\n\nFor the kickoff I would suggest ', + attachments: [attachment('demo-blob-service-agreement')], + keywords: ['$seen', '$draft', '$label:work'], + }), + // Scheduled: in Drafts with a pending submission (see demo-client). + mail({ + id: 'demo-email-scheduled-prep', mailbox: MB.drafts, date: ago(0, 25), + from: me, to: beaconTeam.filter((a) => a.email !== me.email), + subject: 'Go/no-go prep: what I need from each of you by noon', + text: `Morning all,\n\nBefore 14:00 today, please have ready:\n\n- Ahmed: the soak dashboard link and the hour-31 explanation\n- Priya: the open bug list with BEA-412 at the top\n- Lisa: the reconnect numbers from staging\n- Bob: your sign-off on the runbook, or what is missing\n\nI'll bring the rollback plan and the canary tenant list. Short meeting if we do this.\n\nNora`, + keywords: ['$seen', '$draft', '$label:beacon'], + }), + + // ══ Archive ════════════════════════════════════════════════ + mail({ + id: 'demo-email-archive-pto', mailbox: MB.archive, date: at(14, 9, 20), + from: addr(MARIA), to: [{ name: 'All staff', email: `all@${WORK_DOMAIN}` }], + subject: 'Updated leave policy, effective next quarter', + text: 'Dear team,\n\nThe updated leave policy takes effect at the start of next quarter. Key changes: 28 days instead of 26, half days anywhere, and up to ten days roll over.\n\nThe full text is in Notion. Please acknowledge by replying to this mail.\n\nMaria\nPeople Ops', + keywords: ['$seen'], headers: company, + }), + mail({ + id: 'demo-email-archive-hetzner-ticket', mailbox: MB.archive, date: at(21, 15, 4), + from: { name: 'Hetzner Support', email: 'support@hetzner.com' }, + subject: 'Re: Ticket #2026101234: packet loss between fsn1 and hel1', + text: 'Hello Nora,\n\nThank you for the traceroutes. We were able to reproduce the loss on one upstream path and have rerouted it. Please let us know if you see it again.\n\nTicket closed.\n\nBest regards,\nTobias, Hetzner Support', + keywords: ['$seen'], headers: authPass('hetzner.com'), + }), + mail({ + id: 'demo-email-archive-linear-welcome', mailbox: MB.archive, date: at(26, 10, 0), + from: { name: 'Linear', email: 'hello@linear.app' }, + subject: 'Welcome to Linear, Meridian Labs', + text: 'Your workspace is ready. Invite your team, import from Jira if you must, and set up your first cycle.\n\nThe Linear team', + keywords: ['$seen'], headers: authPass('linear.app'), + }), + mail({ + id: 'demo-email-archive-offsite', mailbox: MB.archive2025, date: at(120, 18, 30), + from: addr(RACHEL), to: [{ name: 'All staff', email: `all@${WORK_DOMAIN}` }], + subject: 'Offsite photos', + text: 'The good one from the lake is attached; the rest are in the shared drive under Photos. Thank you all, that was a proper break.\n\nRachel', + attachments: [attachment('demo-blob-team-photo')], + keywords: ['$seen'], headers: company, + }), + mail({ + id: 'demo-email-archive-review', mailbox: MB.archive2025, date: at(200, 11, 15), + from: alice, + subject: 'Your development conversation: notes', + text: 'Nora, thanks for a good conversation. The notes as promised: you want to lead a launch end to end, and you want to speak more outside the company. Both are on the plan for next year. Alice', + keywords: ['$seen'], headers: company, + }), + mail({ + id: 'demo-email-archive-2024-welcome', mailbox: MB.archive2024, date: at(560, 9, 5), + from: addr(MARIA), + subject: 'Welcome to Meridian Labs', + text: 'Hi Nora,\n\nWelcome aboard! Your laptop is on your desk, your accounts are set up, and Alice will meet you at 09:30 on your first day. Lunch is on us.\n\nMaria', + keywords: ['$seen'], headers: company, + }), + mail({ + id: 'demo-email-archive-2024-laptop', mailbox: MB.archive2024, date: at(555, 14, 50), + from: ahmed, + subject: 'Your laptop: setup notes', + text: 'Disk is encrypted, 1Password is installed, the VPN config is in your vault. Shout if anything does not work. Ahmed', + keywords: ['$seen'], headers: company, + }), + + // ══ Trash ══════════════════════════════════════════════════ + mail({ + id: 'demo-email-trash-promo', mailbox: MB.trash, date: at(5, 13, 0), + from: { name: 'CloudConf', email: 'hello@cloudconf.example' }, + subject: 'Last chance: 40% off CloudConf tickets', + text: 'Early-bird pricing ends at midnight. Use code LAST40 at checkout.', + keywords: ['$seen'], headers: { ...authPass('cloudconf.example'), ...listHeaders('CloudConf News', 'cloudconf.example') }, + }), + mail({ + id: 'demo-email-trash-calendar', mailbox: MB.trash, date: at(7, 8, 0), + from: { name: 'Calendar', email: `calendar@${WORK_DOMAIN}` }, + subject: 'Reminder: Beacon weekly in 15 minutes', + text: 'Beacon weekly starts at 10:00 in Room Spree and on Zoom.', + keywords: ['$seen'], + }), + + // ══ Spam ═══════════════════════════════════════════════════ + // Phishing: claims to be Meridian IT, fails DMARC, and the link text does + // not match the link target. + mail({ + id: 'demo-email-spam-phish', mailbox: MB.junk, date: at(1, 2, 3), + from: { name: 'Meridian Labs IT', email: `it-helpdesk@${WORK_DOMAIN}` }, + subject: 'Action required: your mailbox password expires today', + text: 'Your Meridian Labs mailbox password expires in 24 hours. To keep access to your email, verify your account now:\n\nhttps://meridianlabs-sso.verify-account.example/login\n\nIT Helpdesk', + html: '

Your Meridian Labs mailbox password expires in 24 hours. To keep access to your email, verify your account now:

https://sso.meridianlabs.example/password

IT Helpdesk

', + headers: authFail(WORK_DOMAIN, 'verify-account.example', { spam: 12.4, llm: 'SPAM (Impersonates the recipient\'s own IT department, urges immediate action, and the link target does not match the link text.)' }), + }), + mail({ + id: 'demo-email-spam-prize', mailbox: MB.junk, date: at(2, 4, 40), + from: { name: 'Prize Center', email: 'winner@totallylegit.example' }, + subject: 'Congratulations! You have been selected', + text: 'Dear lucky winner,\n\nYou have been selected to receive a prize of considerable value. Click below to claim it immediately.\n\n[This is a demo spam message.]', + headers: authFail('totallylegit.example', 'mailer-7.bulk-send.example', { spam: 9.8 }), + }), + mail({ + id: 'demo-email-spam-crypto', mailbox: MB.junk, date: at(3, 19, 15), + from: { name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }, + subject: 'The coin nobody is talking about yet', + text: 'Three early backers turned a small stake into a fortune in 90 days. Limited spots. No experience needed.\n\n[Demo spam.]', + headers: authFail('cryptogrowth.example', 'cryptogrowth.example', { spam: 7.2 }), + }), + + // ══ Projects / Beacon ══════════════════════════════════════ + mail({ + id: 'demo-email-beacon-naming', mailbox: MB.beacon, date: at(9, 11, 30), + from: michael, to: beaconTeam, + subject: 'Naming decision: it stays "Beacon"', + text: 'Legal cleared it, marketing likes it, and nobody could come up with anything better in three weeks. Beacon it is. Rename the internal repos when convenient, not before launch.\n\nM', + keywords: ['$seen', '$pinned', '$label:beacon'], headers: company, + }), + mail({ + id: 'demo-email-beacon-rfc', mailbox: MB.beacon, date: at(12, 16, 20), + from: bob, to: beaconTeam, + subject: 'RFC: per-tenant retry budgets', + text: 'The RFC is in the repo (docs/rfc/0007-retry-budgets.md). Summary: every tenant gets a budget of retries per hour proportional to its normal volume; when it is spent, retries queue instead of firing. It caps the worst case at 2x delivery volume and stops one customer\'s outage from costing everyone else.\n\nComments in the PR please. Bob', + keywords: ['$seen', '$label:beacon'], headers: company, + }), + mail({ + id: 'demo-email-beacon-sprint', mailbox: MB.beacon, date: at(8, 9, 0), + from: alice, to: beaconTeam, + subject: 'Sprint 41 planning: agenda', + text: '1. Soak test schedule (Ahmed)\n2. Signing key rotation, #1284 (Lisa)\n3. Launch checklist walkthrough (Nora)\n4. What we cut if the canary slips (everyone)\n\nCome with numbers. Alice', + keywords: ['$seen', '$label:beacon'], headers: company, + }), + + // ══ Projects / Website redesign ════════════════════════════ + mail({ + id: 'demo-email-website-feedback-2', thread: 'demo-thread-website-feedback', mailbox: MB.website, date: at(7, 15, 30), + from: addr(SARAH), + subject: 'Re: Design feedback, round 2', + text: 'All taken on board. v3 of the prototype comes with the invoice this week; the hero has two options for you to pick from.\n\nSarah', + keywords: ['$seen', '$label:work'], headers: authPass('designco.example'), + inReplyTo: [mid('demo-email-website-feedback-1')], references: [mid('demo-email-website-feedback-1')], + }), + mail({ + id: 'demo-email-website-feedback-1', thread: 'demo-thread-website-feedback', mailbox: MB.website, date: at(8, 17, 10), + from: me, to: [addr(SARAH)], cc: [addr(RACHEL)], + subject: 'Design feedback, round 2', + text: 'Hi Sarah,\n\nRound two, shorter than round one:\n\n- The pricing table is right. Ship it.\n- The docs navigation needs a search field above the tree, not below.\n- The hero still says "platform" four times. Rachel is rewriting the copy.\n\nNora', + keywords: ['$seen', '$label:work'], + }), + + // ══ Receipts ═══════════════════════════════════════════════ + mail({ + id: 'demo-email-receipt-hetzner', mailbox: MB.receipts, date: at(3, 7, 20), + from: { name: 'Hetzner Online', email: 'billing@hetzner.com' }, + subject: 'Invoice R0012345678: EUR 51.71 (paid)', + text: 'Your invoice R0012345678 for the current period is attached. EUR 51.71 was collected by SEPA direct debit.\n\nHetzner Online GmbH', + attachments: [attachment('demo-blob-hetzner-invoice')], + keywords: ['$seen', '$label:finance'], headers: authPass('hetzner.com'), + }), + mail({ + id: 'demo-email-receipt-stripe', mailbox: MB.receipts, date: at(1, 7, 2), + from: { name: 'Stripe', email: 'receipts@stripe.com' }, subject: 'Your receipt from Linear Inc. [#2451-9928]', - sentAt: demoDate(-1, -1, -22), - preview: 'Receipt from Linear Inc. for $16.00. Thanks for your business.', - hasAttachment: false, - ...bodies( - 'Receipt from Linear Inc.\nAmount paid: $16.00\nDate paid: yesterday\nPayment method: Visa •••• 4242\n\nDescription: Linear Standard (monthly)\n\nReceipt #2451-9928\n\nThis charge will appear on your statement as LINEAR INC.\n\nQuestions? Contact support@linear.app.', - '
Receipt
$16.00
Linear Inc.
Amount$16.00
Payment methodVisa •••• 4242
Receipt number2451-9928

Description: Linear Standard (monthly). This charge will appear on your statement as LINEAR INC.

', - ), - messageId: '', - }, + text: 'Receipt from Linear Inc.\nAmount paid: USD 192.00\nPayment method: Visa ending 4242\n\nDescription: Linear Standard, 12 seats (monthly)\nReceipt #2451-9928\n\nThis charge will appear on your statement as LINEAR INC.', + html: '
Receipt
$192.00
Linear Inc.
Amount$192.00
Payment methodVisa •••• 4242
Receipt number2451-9928

Description: Linear Standard, 12 seats (monthly). This charge will appear on your statement as LINEAR INC.

', + keywords: ['$seen', '$label:finance'], headers: authPass('stripe.com'), + }), + mail({ + id: 'demo-email-receipt-github', mailbox: MB.receipts, date: at(11, 6, 10), + from: { name: 'GitHub', email: 'billing@github.com' }, + subject: '[GitHub] Payment receipt for Meridian Labs', + text: 'Thanks for your payment.\n\nGitHub Team, 18 seats · USD 72.00\nGitHub Actions, 4,200 minutes · USD 33.60\nTotal: USD 105.60 · Visa ending 4242', + keywords: ['$seen', '$label:finance'], headers: authPass('github.com'), + }), + mail({ + id: 'demo-email-receipt-porkbun', mailbox: MB.receipts, date: at(18, 8, 0), + from: { name: 'Porkbun', email: 'support@porkbun.com' }, + subject: 'Renewal confirmation: meridianlabs.example (1 year)', + text: 'Your domain meridianlabs.example has been renewed for one year.\n\nAmount: USD 11.06\nAuto-renew: on\n\nReply to this mail if you need an invoice with your VAT id.', + keywords: ['$seen', '$label:finance'], headers: authPass('porkbun.com'), + }), - // Email with attachments - invoice - { - id: 'demo-email-4', - threadId: 'demo-thread-4', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 245000, - receivedAt: demoDate(0, -6), - from: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }], - to: [USER], - subject: 'Invoice #2024-089 & landing-page prototype v3', - sentAt: demoDate(0, -6), - preview: "Hi, please find attached the invoice for October and a screenshot of the latest prototype...", - hasAttachment: true, - ...textOnly( - "Hi,\n\nPlease find attached the invoice for October and a screenshot of the latest prototype. I went with Option B for the hero (the one with the asymmetric grid) since you mentioned the symmetrical version felt too flat in our last call.\n\nIf the invoice line items look off, ping me - I had to back out the November pre-payment.\n\nBest regards,\nSarah", - ), - attachments: [ - { partId: 'att-1', blobId: 'demo-blob-att-1', size: 145000, name: 'Invoice-2024-089.pdf', type: 'application/pdf' }, - { partId: 'att-2', blobId: 'demo-blob-att-2', size: 21864, name: 'prototype-v3.png', type: 'image/png' }, - ], - messageId: '', - }, + // ══ Travel ═════════════════════════════════════════════════ + mail({ + id: 'demo-email-travel-accepted', mailbox: MB.travel, date: at(24, 10, 20), + from: addr(KENJI), + subject: 'Infra Summit Lisbon: your talk is accepted', + text: 'Dear Nora,\n\nThe programme committee accepted "Rolling back without rolling over: safe launches for event platforms". Congratulations, it was one of the highest-rated proposals.\n\nWe cover travel and two hotel nights. Logistics follow closer to the date.\n\nKenji Watanabe\nProgramme chair', + keywords: ['$seen', '$flagged', '$label:travel'], headers: authPass('infrasummit.example'), + }), + mail({ + id: 'demo-email-travel-db', mailbox: MB.travel, date: at(40, 19, 5), + from: { name: 'Deutsche Bahn', email: 'noreply@bahn.de' }, + subject: 'Your ticket: Berlin Hbf to Hamburg Hbf', + text: 'Thank you for your booking.\n\nICE 802 · Berlin Hbf 07:34 to Hamburg Hbf 09:21 · 2nd class, seat 42, coach 7\nReturn: ICE 1601 · Hamburg Hbf 18:46 to Berlin Hbf 20:35\n\nYour ticket is in the DB Navigator app.', + keywords: ['$seen', '$label:travel'], headers: authPass('bahn.de'), + }), - // Carlos - starred, social - { - id: 'demo-email-5', - threadId: 'demo-thread-5', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true, $flagged: true }, - size: 2800, - receivedAt: demoDate(-2, -1), - from: [{ name: 'Carlos Rivera', email: 'carlos.rivera@example.com' }], - to: [USER], - subject: 'Friday dinner - moved to 7:30 (sorry!)', - sentAt: demoDate(-2, -1), - preview: 'Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating...', - hasAttachment: false, - ...textOnly( - "Quick heads up - had to push the dinner back half an hour. Bistro could only do the late seating.\n\nNew time: Friday, 7:30 PM\nThe Garden Bistro, 123 Oak Street\n\nReservation under my name, 8 people. Let me know if that doesn't work for you and I can try to wrangle something.\n\nCheers,\nCarlos", - ), - messageId: '', - }, - - // Linear - issue assigned - { - id: 'demo-email-linear', - threadId: 'demo-thread-linear', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 5400, - receivedAt: demoDate(0, -7, -15), - from: [{ name: 'Linear', email: 'notifications@linear.app' }], - to: [USER], - subject: 'BUL-2031 was assigned to you - "Compose: drag-and-drop attachments duplicated on slow networks"', - sentAt: demoDate(0, -7, -15), - preview: 'Priya Sharma assigned this issue to you. Repro on a throttled connection (Slow 3G): drop a file twice and...', - hasAttachment: false, - ...bodies( - "Priya Sharma assigned BUL-2031 to you.\n\nTitle: Compose: drag-and-drop attachments duplicated on slow networks\nPriority: Medium\n\nRepro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn't get debounced and both attempts complete, so the attachment shows up twice in the draft.\n\nOpen in Linear: https://linear.app/bulwark/issue/BUL-2031", - '
Linear · BUL-2031
Compose: drag-and-drop attachments duplicated on slow networks
Priya Sharma assigned this issue to you · Priority Medium
Repro on a throttled connection (Slow 3G): drop a file twice in quick succession into the compose drop zone. The first upload doesn\'t get debounced and both attempts complete, so the attachment shows up twice in the draft.
Open in Linear
', - ), - messageId: '', - }, - - // Anna - sister, photos - { - id: 'demo-email-anna', - threadId: 'demo-thread-anna', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 4800000, - receivedAt: demoDate(-1, -19), - from: [{ name: 'Anna Kowalski', email: 'anna.kowalski@example.com' }], - to: [USER], - subject: 'photos from the wedding', - sentAt: demoDate(-1, -19), - preview: "finally got around to going through these. there are like 600 more on the drive but here's the highlights...", - hasAttachment: true, - ...textOnly( - "ok finally got around to going through these. there are like 600 more on the drive but here's 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: [ - { partId: 'att-3', blobId: 'demo-blob-att-3', size: 170392, name: 'wedding-001.jpg', type: 'image/jpeg' }, - { partId: 'att-4', blobId: 'demo-blob-att-4', size: 154740, name: 'wedding-014-mom-dad.jpg', type: 'image/jpeg' }, - { partId: 'att-5', blobId: 'demo-blob-att-5', size: 133897, name: 'wedding-038-the-toast.jpg', type: 'image/jpeg' }, - ], - messageId: '', - }, - - // AWS billing - { - id: 'demo-email-aws', - threadId: 'demo-thread-aws', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 9100, - receivedAt: demoDate(-2, -3, -45), - from: [{ name: 'AWS Billing', email: 'no-reply-aws@amazon.com' }], - to: [USER], - subject: 'Your AWS bill is available - $127.43', - sentAt: demoDate(-2, -3, -45), - preview: 'Your bill for the previous billing period is now available. Total this period: $127.43 (down $4.12)...', - hasAttachment: false, - ...textOnly( - "Your bill for the previous billing period is now available.\n\nTotal this period: $127.43 (down $4.12 from last period)\n\nTop services:\n EC2 - $61.20\n S3 - $28.94\n Route 53 - $14.50\n CloudFront - $11.02\n Other - $11.77\n\nView the full invoice in the Billing Console.", - ), - messageId: '', - }, - - // 2FA code - system, unread - { - id: 'demo-email-2fa', - threadId: 'demo-thread-2fa', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 1700, - receivedAt: demoDate(0, -1, -8), - from: [{ name: '1Password', email: 'noreply@1password.com' }], - to: [USER], - subject: 'Your one-time verification code is 814-302', - sentAt: demoDate(0, -1, -8), - preview: "Use this code within 10 minutes to sign in. If you didn't request it, ignore this email.", - hasAttachment: false, - ...textOnly( - "Your verification code: 814-302\n\nUse this code within 10 minutes to sign in. If you didn't request it, you can safely ignore this email - your account remains secure.", - ), - messageId: '', - }, - - // LinkedIn - cold-ish - { - id: 'demo-email-linkedin', - threadId: 'demo-thread-linkedin', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 8200, - receivedAt: demoDate(-3, -11), - from: [{ name: 'LinkedIn', email: 'jobs-noreply@linkedin.com' }], - to: [USER], - subject: '5 jobs matching "staff engineer · remote · eu" - including one at Datadog', - sentAt: demoDate(-3, -11), - preview: "We thought you'd be interested in these jobs based on your profile and search history.", - hasAttachment: false, - ...textOnly( - 'Based on your saved search "staff engineer · remote · eu":\n\n1. Staff Software Engineer - Datadog (Remote, EU)\n2. Principal Engineer, Platform - Sentry (Remote, EU)\n3. Staff Backend Engineer - Linear (Remote)\n4. Tech Lead, Infrastructure - Tailscale (Remote, EU)\n5. Staff Engineer, Mobile - Notion (Remote, EU)\n\nManage job alerts at linkedin.com/jobs/preferences.', - ), - messageId: '', - }, - - // Book club - Marcus - { - id: 'demo-email-bookclub', - threadId: 'demo-thread-bookclub', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 2400, - receivedAt: demoDate(-1, -14), - from: [{ name: 'Marcus Hughes', email: 'marcus.hughes@example.com' }], - to: [USER, { name: 'Emma Wilson', email: 'emma.wilson@example.com' }, { name: 'David Park', email: 'david.park@example.com' }], - subject: 'book club thursday - picking the next one', - sentAt: demoDate(-1, -14), - preview: 'Reminder: 7pm at mine. We finish off Le Guin and pick the next read. My vote is the Calvino but I know Emma...', - hasAttachment: false, - ...textOnly( - "Reminder: 7pm at mine. We finish off Le Guin and pick the next read.\n\nMy vote is the Calvino but I know Emma's been pushing for the Knausgaard. I'll bring wine, can someone else handle snacks?\n\nm", - ), - messageId: '', - }, - - // DHL package - { - id: 'demo-email-dhl', - threadId: 'demo-thread-dhl', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 5600, - receivedAt: demoDate(0, -9, -30), - from: [{ name: 'DHL Express', email: 'noreply@dhl.com' }], - to: [USER], - subject: 'Your package is out for delivery - arriving today', - sentAt: demoDate(0, -9, -30), - preview: 'Tracking 1Z 999 AA1 0123 4567 84 · Estimated delivery: today between 14:00 and 18:00.', - hasAttachment: false, - ...textOnly( - 'Your package is on the truck.\n\nTracking: 1Z 999 AA1 0123 4567 84\nEstimated delivery window: today, 14:00–18:00\n\nIf no one is home, the driver will attempt redelivery tomorrow or leave it at the nearest pickup point.\n\nTrack live at dhl.com/track.', - ), - messageId: '', - }, - - // Notion - { - id: 'demo-email-notion', - threadId: 'demo-thread-notion', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 4100, - receivedAt: demoDate(-2, -16), - from: [{ name: 'Olivia Bennett (via Notion)', email: 'team@mail.notion.so' }], - to: [USER], - subject: 'Olivia shared "Q1 2026 - design north star" with you', - sentAt: demoDate(-2, -16), - preview: 'Olivia Bennett shared a page with you in the Northwind workspace. Open in Notion to view.', - hasAttachment: false, - ...textOnly( - 'Olivia Bennett shared a page with you in the Northwind workspace.\n\n"Q1 2026 - design north star"\n\nOpen in Notion: https://notion.so/northwind/q1-design-north-star', - ), - messageId: '', - }, - - // Spotify wrap - { - id: 'demo-email-spotify', - threadId: 'demo-thread-spotify', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 7400, - receivedAt: demoDate(-4, -8), - from: [{ name: 'Spotify', email: 'no-reply@spotify.com' }], - to: [USER], - subject: 'Your year in music is ready', - sentAt: demoDate(-4, -8), - preview: 'You spent 38,420 minutes listening this year. Your top artist was Big Thief, and your top genre was indie folk.', - hasAttachment: false, - ...textOnly( - 'Your year, in music.\n\n38,420 minutes listened\nTop artist: Big Thief\nTop song: "Vampire Empire"\nTop genre: indie folk\nDiscover Weekly hit rate: 41%\n\nOpen Spotify to see your full Wrapped.', - ), - messageId: '', - }, - - // Booking.com confirmation - { - id: 'demo-email-booking', - threadId: 'demo-thread-booking', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 32100, - receivedAt: demoDate(-5, -10), - from: [{ name: 'Booking.com', email: 'no-reply@booking.com' }], - to: [USER], - subject: 'Confirmation 4892-7714-3320 - Hotel Lago, Lake Como (Dec 22–25)', - sentAt: demoDate(-5, -10), - preview: 'Your booking is confirmed. Check-in: Dec 22, after 15:00. Check-out: Dec 25, before 11:00.', - hasAttachment: true, - ...textOnly( - 'Your booking is confirmed.\n\nHotel Lago, Lake Como (Italy)\nCheck-in: Dec 22, after 15:00\nCheck-out: Dec 25, before 11:00\n\nRoom: Lake-view double, breakfast included\nTotal: €612 (paid)\n\nConfirmation number: 4892-7714-3320\n\nYour voucher is attached. Show it at reception.', - ), - attachments: [ - { partId: 'att-6', blobId: 'demo-blob-att-6', size: 31000, name: 'booking-voucher-4892-7714-3320.pdf', type: 'application/pdf' }, - ], - messageId: '', - }, - - // Substack post - { - id: 'demo-email-substack', - threadId: 'demo-thread-substack', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: { $seen: true }, - size: 22400, - receivedAt: demoDate(-1, -12), - from: [{ name: 'Robin Sloan', email: 'robin@substack.com' }], - to: [USER], + // ══ Newsletters ════════════════════════════════════════════ + mail({ + id: 'demo-email-nl-substack', mailbox: MB.newsletters, date: at(2, 6, 12), + from: { name: 'Robin Sloan', email: 'robin@substack.com' }, subject: 'a small newsletter about a small forge', - sentAt: demoDate(-1, -12), - preview: 'I have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of...', - hasAttachment: false, - ...textOnly( - "Hello, friends.\n\nI have been spending the slow weeks of November in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one's soul.\n\nWhat I'm reading: Annie Dillard, again. \"The Writing Life\". Specifically the chapter about her cabin, which I read every year around this time and which always makes me want to throw my laptop into the sea.\n\nWhat I'm watching: very little. There is something about December that makes television feel like an admission of defeat.\n\nUntil next month -\nR.", - ), - messageId: '', - }, + text: 'Hello, friends.\n\nI have been spending the slow weeks in the workshop, slowly forging a knife from a piece of railway track. It is going badly, in the way that is good for one\'s soul.\n\nWhat I\'m reading: Annie Dillard, again. "The Writing Life". Specifically the chapter about her cabin, which always makes me want to throw my laptop into the sea.\n\nUntil next month,\nR.', + keywords: ['$seen'], headers: { ...authPass('substack.com'), ...listHeaders('Robin Sloan', 'substack.com') }, + }), + mail({ + id: 'demo-email-nl-pragmatic', mailbox: MB.newsletters, date: at(4, 7, 0), + from: { name: 'The Pragmatic Engineer', email: 'pragmaticengineer@substack.com' }, + subject: 'How launch reviews work at companies that ship well', + text: 'This week: what a good go/no-go looks like, why the best teams write the rollback plan before the launch plan, and a reader question about canary sizes.\n\nRead online or reply with your own launch stories.', + keywords: ['$seen'], headers: { ...authPass('substack.com'), ...listHeaders('The Pragmatic Engineer', 'substack.com') }, + }), + mail({ + id: 'demo-email-nl-postgres', mailbox: MB.newsletters, date: at(6, 5, 45), + from: { name: 'Postgres Weekly', email: 'postgres@cooperpress.com' }, + subject: '#612: Logical replication in 18, and a tale of two vacuums', + text: 'Postgres Weekly #612\n\n- What changed in logical replication in Postgres 18\n- A tale of two vacuums: autovacuum tuning for append-mostly tables\n- pg_stat_io, one year on\n\nUnsubscribe or change your address at the link below.', + keywords: ['$seen'], headers: { ...authPass('cooperpress.com'), ...listHeaders('Postgres Weekly', 'cooperpress.com') }, + }), - // Recruiter cold outreach - { - id: 'demo-email-recruiter', - threadId: 'demo-thread-recruiter', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 3200, - receivedAt: demoDate(0, -10), - from: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }], - to: [USER], - subject: 'Senior role - Distributed Systems - €180-220k + equity', - sentAt: demoDate(0, -10), - preview: "Hi, I came across your profile and thought you'd be a great fit for a senior position with one of our clients...", - hasAttachment: false, - ...textOnly( - "Hi,\n\nI came across your profile and thought you'd be a great fit for a senior position with one of our clients - a well-funded Series B (real-time data infrastructure, 60-person eng team, fully remote within EU).\n\nThe core stack: Rust + Postgres + a non-trivial amount of Go. Hiring level is roughly equivalent to Staff at FAANG.\n\nWould you be open to a 15-minute call this week or next?\n\nBest,\nJennifer Hayes\nTalent Partners", - ), - messageId: '', - }, - - // Dentist reminder - { - id: 'demo-email-dentist', - threadId: 'demo-thread-dentist', - mailboxIds: { 'demo-mailbox-inbox': true }, - keywords: {}, - size: 2200, - receivedAt: demoDate(-1, -2), - from: [{ name: "Dr. Smith's Office", email: 'appointments@drsmith.example' }], - to: [USER], - subject: 'Appointment reminder - Tuesday at 10:00', - sentAt: demoDate(-1, -2), - preview: 'This is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM.', - hasAttachment: false, - ...textOnly( - "Hello,\n\nThis is a friendly reminder of your upcoming cleaning appointment on Tuesday at 10:00 AM with Dr. Smith.\n\nLocation: 123 Medical Plaza, Suite 4\n\nNeed to reschedule? Reply to this email or call (555) 010-7878.\n\nSee you Tuesday!\nDr. Smith's office", - ), - messageId: '', - }, - - // ── Sent ──────────────────────────────────────────────────── - { - id: 'demo-email-6', - threadId: 'demo-thread-6', - mailboxIds: { 'demo-mailbox-sent': true }, - keywords: { $seen: true }, - size: 2100, - receivedAt: demoDate(-1, -4), - from: [USER], - to: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], - subject: 'Updated Requirements Document', - sentAt: demoDate(-1, -4), - preview: "Hi Alice, I've updated the requirements document with the changes we discussed...", - hasAttachment: false, - ...textOnly( - "Hi Alice,\n\nI've updated the requirements document with the changes we discussed in yesterday's meeting. The main updates are in sections 3 and 5.\n\nLet me know if you have any questions.\n\nBest,\nDemo User", - ), - messageId: '', - }, - { - id: 'demo-email-7', - threadId: 'demo-thread-7', - mailboxIds: { 'demo-mailbox-sent': true }, - keywords: { $seen: true }, - size: 1800, - receivedAt: demoDate(-4, -2), - from: [USER], - to: [{ name: 'Sarah Kim', email: 'sarah.kim@example.com' }], - subject: 'Re: Design Feedback', - sentAt: demoDate(-4, -2), - preview: 'Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements...', - hasAttachment: false, - ...textOnly( - "Thanks Sarah! The new color scheme looks great. I especially like the contrast improvements for accessibility.\n\nLet's go with Option B for the navigation.\n\nBest,\nDemo User", - ), - messageId: '', - }, - { - id: 'demo-email-sent-mom', - threadId: 'demo-thread-mom', - mailboxIds: { 'demo-mailbox-sent': true }, - keywords: { $seen: true }, - size: 1400, - receivedAt: demoDate(0, -2, -10), - from: [USER], - to: [{ name: 'Sofia Russo', email: 'sofia.russo@example.com' }], - subject: 'Re: when are you coming home?', - sentAt: demoDate(0, -2, -10), - preview: "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend...", - hasAttachment: false, - ...textOnly( - "Mom - I miss you too. Let me check the calendar tonight and I'll get back to you tomorrow about the weekend. Lemons sound like a bribe and I will not pretend otherwise.\n\nLove you both.", - ), - messageId: '', - inReplyTo: ['<5a8c-mom@example.com>'], - references: ['<5a8c-mom@example.com>'], - }, - - // ── Drafts ────────────────────────────────────────────────── - { - id: 'demo-email-8', - threadId: 'demo-thread-8', - mailboxIds: { 'demo-mailbox-drafts': true }, - keywords: { $seen: true, $draft: true }, - size: 900, - receivedAt: demoDate(0, -1), - from: [USER], - to: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }], - subject: 'Meeting Notes - Draft', - sentAt: demoDate(0, -1), - preview: "Here are the notes from today's standup...", - hasAttachment: false, - ...textOnly( - "Here are the notes from today's standup:\n\n- API integration on track\n- Need to resolve the caching issue\n- ", - ), - messageId: '', - }, - { - id: 'demo-email-draft-recruiter', - threadId: 'demo-thread-draft-recruiter', - mailboxIds: { 'demo-mailbox-drafts': true }, - keywords: { $seen: true, $draft: true }, - size: 720, - receivedAt: demoDate(0, -8), - from: [USER], - to: [{ name: 'Jennifer Hayes', email: 'jennifer@talent-partners.example' }], - subject: 'Re: Senior role - Distributed Systems', - sentAt: demoDate(0, -8), - preview: "Hi Jennifer, thanks for reaching out. I'm not actively looking, but the role sounds interesting enough that...", - hasAttachment: false, - ...textOnly( - "Hi Jennifer,\n\nThanks for reaching out. I'm not actively looking, but the role sounds interesting enough that I'd be open to a quick call. A few questions before we set something up:\n\n- ", - ), - messageId: '', - }, - - // ── Trash ─────────────────────────────────────────────────── - { - id: 'demo-email-9', - threadId: 'demo-thread-9', - mailboxIds: { 'demo-mailbox-trash': true }, - keywords: { $seen: true }, - size: 15200, - receivedAt: demoDate(-5, -3), - from: [{ name: 'Promo Store', email: 'deals@promostore.example' }], - to: [USER], - subject: '🎉 Flash Sale: 50% Off Everything!', - sentAt: demoDate(-5, -3), - preview: 'Limited time offer! Get 50% off all items in our store...', - hasAttachment: false, - ...textOnly( - 'Limited time offer! Get 50% off all items in our store. Use code FLASH50 at checkout.', - ), - messageId: '', - }, - { - id: 'demo-email-10', - threadId: 'demo-thread-10', - mailboxIds: { 'demo-mailbox-trash': true }, - keywords: { $seen: true }, - size: 2300, - receivedAt: demoDate(-7, 0), - from: [{ name: 'System Notification', email: 'noreply@service.example' }], - to: [USER], - subject: 'Your password was changed', - sentAt: demoDate(-7, 0), - preview: 'Your account password was successfully changed on...', - hasAttachment: false, - ...textOnly( - 'Your account password was successfully changed. If you did not make this change, please contact support immediately.', - ), - messageId: '', - }, - - // ── Projects ──────────────────────────────────────────────── - { - id: 'demo-email-11', - threadId: 'demo-thread-11', - mailboxIds: { 'demo-mailbox-projects': true }, - keywords: { $seen: true, $flagged: true }, - size: 4500, - receivedAt: demoDate(-2, -7), - from: [{ name: 'Alice Johnson', email: 'alice.johnson@example.com' }], - to: [USER], - subject: '[Project] Sprint Planning Agenda', - sentAt: demoDate(-2, -7), - preview: "Here's the agenda for next week's sprint planning session...", - hasAttachment: false, - ...textOnly( - "Hi team,\n\nHere's the agenda for next week's sprint planning:\n\n1. Review previous sprint velocity\n2. Discuss tech debt items\n3. Prioritize backlog\n4. Assign story points\n5. Capacity planning\n\nPlease come prepared with your updates.\n\nThanks,\nAlice", - ), - messageId: '', - }, - { - id: 'demo-email-12', - threadId: 'demo-thread-12', - mailboxIds: { 'demo-mailbox-projects': true }, - keywords: {}, - size: 3200, - receivedAt: demoDate(0, -8), - from: [{ name: 'Bob Chen', email: 'bob.chen@example.com' }], - to: [USER], - subject: '[Project] API Rate Limiting Discussion', - sentAt: demoDate(0, -8), - preview: "I've been thinking about our rate limiting approach and wanted to propose a few changes...", - hasAttachment: false, - ...textOnly( - "Hey,\n\nI've been thinking about our rate limiting approach and wanted to propose:\n\n1. Token bucket algorithm instead of fixed window\n2. Per-endpoint limits rather than global\n3. Graduated response (warn → throttle → block)\n\nThoughts? I can put together a more detailed RFC if we agree on the direction.\n\n- Bob", - ), - messageId: '', - }, - { - id: 'demo-email-roadmap', - threadId: 'demo-thread-roadmap', - mailboxIds: { 'demo-mailbox-projects': true }, - keywords: {}, - size: 4900, - receivedAt: demoDate(-1, -15), - from: [{ name: 'Michael Torres', email: 'michael.torres@company.example' }], - to: [USER, { name: 'Alice Johnson', email: 'alice.johnson@example.com' }, { name: 'James Miller', email: 'james.miller@company.example' }], - subject: '[Project] Q1 2026 roadmap - first cut', - sentAt: demoDate(-1, -15), - preview: 'Attached is the first cut of the Q1 roadmap. Three themes: reliability, mobile, and the long-promised...', - hasAttachment: true, - ...textOnly( - "Team,\n\nAttached is the first cut of the Q1 roadmap. Three themes:\n\n1. Reliability (Alice's team)\n2. Mobile parity (cross-functional)\n3. The long-promised search rework (James, this is mostly on you)\n\nLet's leave comments in the doc rather than do a meeting - I'd rather have the meeting be the *decisions*, not the discussion. Closing comments end-of-week.\n\nM", - ), - attachments: [ - { partId: 'att-7', blobId: 'demo-blob-att-7', size: 84000, name: 'Q1-2026-roadmap-v0.pdf', type: 'application/pdf' }, - ], - messageId: '', - }, - - // ── Archive ───────────────────────────────────────────────── - { - id: 'demo-email-13', - threadId: 'demo-thread-13', - mailboxIds: { 'demo-mailbox-archive': true }, - keywords: { $seen: true }, - size: 2600, - receivedAt: demoDate(-14, -6), - from: [{ name: 'Maria Lopez', email: 'maria.lopez@company.example' }], - to: [USER], - subject: 'Updated PTO Policy - Effective January 1', - sentAt: demoDate(-14, -6), - preview: 'Please review the updated PTO policy that takes effect January 1st...', - hasAttachment: false, - ...textOnly( - 'Dear team,\n\nPlease review the updated PTO policy effective January 1st. Key changes include:\n\n- Increased annual allowance from 20 to 25 days\n- Flexible half-day options\n- Rollover limit increased to 10 days\n\nPlease acknowledge receipt.\n\nBest,\nMaria - People Ops', - ), - messageId: '', - }, - { - id: 'demo-email-archive-support', - threadId: 'demo-thread-archive-support', - mailboxIds: { 'demo-mailbox-archive': true }, - keywords: { $seen: true }, - size: 3400, - receivedAt: demoDate(-21, -4), - from: [{ name: 'Fastmail Support', email: 'support@fastmail.com' }], - to: [USER], - subject: 'Re: Ticket #438201 - DKIM signing fails on cross-account aliases', - sentAt: demoDate(-21, -4), - preview: "Thanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution...", - hasAttachment: false, - ...textOnly( - "Hi,\n\nThanks for the additional logs. We were able to reproduce on our side - the issue was indeed the alias resolution path skipping the DKIM signer step. Fix has been deployed to the AU and SY clusters; EU rolls out tomorrow.\n\nResolved on our end. Please reopen if you see anything related.\n\nBest,\nClaire - Fastmail Support", - ), - messageId: '', - }, - - // ── Receipts ──────────────────────────────────────────────── - { - id: 'demo-email-14', - threadId: 'demo-thread-14', - mailboxIds: { 'demo-mailbox-receipts': true }, - keywords: { $seen: true }, - size: 5200, - receivedAt: demoDate(-3, -12), - from: [{ name: 'Hetzner', email: 'billing@hetzner.com' }], - to: [USER], - subject: 'Invoice #INV-2024-1042 - €49.99 (paid)', - sentAt: demoDate(-3, -12), - preview: 'Your payment of €49.99 has been processed successfully...', - hasAttachment: true, - ...textOnly( - 'Payment Confirmation\n\nAmount: €49.99\nDate: 3 days ago\nInvoice: INV-2024-1042\nService: CX22 dedicated (Helsinki, monthly)\n\nThank you for your payment.', - ), - attachments: [ - { partId: 'att-8', blobId: 'demo-blob-att-8', size: 28000, name: 'INV-2024-1042.pdf', type: 'application/pdf' }, - ], - messageId: '', - }, - { - id: 'demo-email-receipts-domain', - threadId: 'demo-thread-receipts-domain', - mailboxIds: { 'demo-mailbox-receipts': true }, - keywords: { $seen: true }, - size: 3100, - receivedAt: demoDate(-9, -8), - from: [{ name: 'Porkbun', email: 'support@porkbun.com' }], - to: [USER], - subject: 'Renewal confirmation - example.com (1 year)', - sentAt: demoDate(-9, -8), - preview: 'Your domain example.com has been renewed for 1 year. Next renewal: 11 months from today.', - hasAttachment: false, - ...textOnly( - "Hi,\n\nYour domain example.com has been renewed for 1 year.\n\nAmount: $11.06\nNext renewal: 11 months from today\nAutorenew: on\n\nReply to this email if you need a tax-receipt-style invoice.\n\n- Porkbun", - ), - messageId: '', - }, - - // ── Spam ──────────────────────────────────────────────────── - { - id: 'demo-email-15', - threadId: 'demo-thread-15', - mailboxIds: { 'demo-mailbox-junk': true }, - keywords: {}, - size: 8900, - receivedAt: demoDate(-1, -9), - from: [{ name: 'Prize Center', email: 'winner@totallylegit.example' }], - to: [USER], - subject: 'Congratulations! You Won $1,000,000!!!', - sentAt: demoDate(-1, -9), - preview: 'Dear lucky winner, you have been selected to receive one million dollars...', - hasAttachment: false, - ...textOnly( - 'Dear lucky winner,\n\nYou have been selected to receive ONE MILLION DOLLARS! Click below to claim your prize immediately.\n\n[This is a demo spam email]', - ), - messageId: '', - }, - { - id: 'demo-email-spam-phish', - threadId: 'demo-thread-spam-phish', - mailboxIds: { 'demo-mailbox-junk': true }, - keywords: {}, - size: 4600, - receivedAt: demoDate(-2, -3), - from: [{ name: 'Secure Banking', email: 'security-alert@secur1ty-bank.example' }], - to: [USER], - subject: 'URGENT: Unusual activity on your account - verify within 24 hours', - sentAt: demoDate(-2, -3), - preview: "We've detected suspicious activity. Click below to verify your identity or your account will be suspended...", - hasAttachment: false, - ...textOnly( - "We've detected suspicious activity on your account. To prevent suspension, please verify your details within 24 hours by clicking the link below.\n\n[Phishing demo - never click links like this in real life.]", - ), - messageId: '', - }, - { - id: 'demo-email-spam-crypto', - threadId: 'demo-thread-spam-crypto', - mailboxIds: { 'demo-mailbox-junk': true }, - keywords: {}, - size: 6800, - receivedAt: demoDate(-3, -19), - from: [{ name: 'CryptoGrowth Daily', email: 'invest@cryptogrowth.example' }], - to: [USER], - subject: '🚀 The coin Elon won\'t tell you about - 1000x potential', - sentAt: demoDate(-3, -19), - preview: 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier...', - hasAttachment: false, - ...textOnly( - 'Three early backers turned $500 into $5M in 90 days. Today, you have a chance to get in even earlier. Limited spots. No experience needed.\n\n[Demo spam.]', - ), - messageId: '', - }, + // A mail to the alias, so the identity picker has something to show. + mail({ + id: 'demo-email-alias', mailbox: MB.inbox, date: at(5, 14, 22), + from: { name: 'Infra Summit', email: 'speakers@infrasummit.example' }, to: [addr(NORA_ALIAS)], + subject: 'Speaker profile: please check your bio and photo', + text: 'Your speaker profile is live at infrasummit.example/speakers/nora-lindqvist. Please check the bio and the photo; reply to this mail with changes by the end of the week.', + keywords: ['$seen', '$label:travel'], headers: { ...authPass('infrasummit.example'), ...listHeaders('Infra Summit Speakers', 'infrasummit.example') }, + }), ]; } diff --git a/lib/demo/fixtures/files.ts b/lib/demo/fixtures/files.ts index 28778634f..2b9fdad0b 100644 --- a/lib/demo/fixtures/files.ts +++ b/lib/demo/fixtures/files.ts @@ -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), ]; } diff --git a/lib/demo/fixtures/filters.ts b/lib/demo/fixtures/filters.ts index b477d14ec..38dc1f1f6 100644 --- a/lib/demo/fixtures/filters.ts +++ b/lib/demo/fixtures/filters.ts @@ -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 { 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'), }; diff --git a/lib/demo/fixtures/identities.ts b/lib/demo/fixtures/identities.ts index cc1e271df..ecbc94bfc 100644 --- a/lib/demo/fixtures/identities.ts +++ b/lib/demo/fixtures/identities.ts @@ -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: '

Best regards,
Demo User
Bulwark Mail Demo

', + name: NORA.name, + email: NORA.email, + textSignature: 'Nora Lindqvist\nEngineering Lead, Meridian Labs\n+49 30 555 0199', + htmlSignature: '

Nora Lindqvist
Engineering Lead, Meridian Labs
+49 30 555 0199

', 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: '

Nora Lindqvist
Meridian Labs

', + 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: '

Nora, for Meridian Support
support@meridianlabs.example

', mayDelete: true, }, ]; diff --git a/lib/demo/fixtures/keywords.ts b/lib/demo/fixtures/keywords.ts new file mode 100644 index 000000000..22a4e82a2 --- /dev/null +++ b/lib/demo/fixtures/keywords.ts @@ -0,0 +1,15 @@ +import type { KeywordDefinition } from '@/stores/settings-store'; + +/** + * The persona's tags. The mail fixtures set these as `$label:` 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' }, +]; diff --git a/lib/demo/fixtures/mail.ts b/lib/demo/fixtures/mail.ts new file mode 100644 index 000000000..753c1235e --- /dev/null +++ b/lib/demo/fixtures/mail.ts @@ -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 { + 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

; single newlines become
. */ +export function htmlFromText(text: string): string { + const escape = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + return text + .trim() + .split(/\n{2,}/) + .map((para) => `

${escape(para).replace(/\n/g, '
')}

`) + .join(''); +} + +// ── Headers ────────────────────────────────────────────────── +/** The receiving server's results for mail that checks out. */ +export function authPass(domain: string, opts: { spam?: number } = {}): Record { + 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 { + 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 { + const slug = listName.toLowerCase().replace(/[^a-z0-9]+/g, '-'); + return { + 'List-Id': `${listName} <${slug}.${domain}>`, + 'List-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; + 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}>`; diff --git a/lib/demo/fixtures/mailboxes.ts b/lib/demo/fixtures/mailboxes.ts index 328d94184..e02d9ff12 100644 --- a/lib/demo/fixtures/mailboxes.ts +++ b/lib/demo/fixtures/mailboxes.ts @@ -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:` 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), ]; } diff --git a/lib/demo/fixtures/people.ts b/lib/demo/fixtures/people.ts new file mode 100644 index 000000000..15bf08886 --- /dev/null +++ b/lib/demo/fixtures/people.ts @@ -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 }); diff --git a/lib/demo/fixtures/personal.ts b/lib/demo/fixtures/personal.ts new file mode 100644 index 000000000..95d24f62c --- /dev/null +++ b/lib/demo/fixtures/personal.ts @@ -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: '

Nora

', + 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: '
Album cover

New release from Big Thief

"Double Infinity Sessions". Based on your listening, we think you\'ll like it.

Listen now
', + 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'), + }), + ]; +} diff --git a/lib/demo/fixtures/support.ts b/lib/demo/fixtures/support.ts new file mode 100644 index 000000000..dc6554c40 --- /dev/null +++ b/lib/demo/fixtures/support.ts @@ -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 }; diff --git a/lib/demo/fixtures/tasks.ts b/lib/demo/fixtures/tasks.ts index 064d6820a..c2971f2d5 100644 --- a/lib/demo/fixtures/tasks.ts +++ b/lib/demo/fixtures/tasks.ts @@ -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)), ]; } diff --git a/lib/demo/fixtures/templates.ts b/lib/demo/fixtures/templates.ts new file mode 100644 index 000000000..542d5e080 --- /dev/null +++ b/lib/demo/fixtures/templates.ts @@ -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: '

Hi {{recipient_name}},

Thanks for your time today. What we agreed:

Next step is on my side; I\'ll come back to you by {{date}}.

Nora

', + 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: '

Hi {{recipient_name}},

The two things that usually go wrong with signature verification:

  1. Sign the raw request body, not the decoded JSON.
  2. The timestamp in X-Beacon-Timestamp is in seconds and the tolerance is five minutes.

If that does not fix it, send me one failing request (headers and body) and I\'ll look at it with you.

{{sender_name}}, for Meridian Support

', + 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: '

Hi all,

I\'m out from {{date}}. While I\'m away:

  • Launch questions: Alice
  • Customer questions: support@meridianlabs.example
  • Anything on fire: call me, don\'t mail

Nora

', + isHTML: true, + category: 'Work', + defaultRecipients: { to: ['alice.johnson@meridianlabs.example'], cc: ['bob.chen@meridianlabs.example'] }, + isFavorite: false, + createdAt: demoDate(-200), + updatedAt: demoDate(-200), + }, + ]; +} diff --git a/lib/demo/fixtures/vacation.ts b/lib/demo/fixtures/vacation.ts index 96badaa82..927f1a844 100644 --- a/lib/demo/fixtures/vacation.ts +++ b/lib/demo/fixtures/vacation.ts @@ -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: '

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.

', + 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: '

Thank you for your mail. I am at Infra Summit in Lisbon and reading mail slowly until next week.

For anything about the Beacon launch, Alice Johnson has the plan. For customer questions, support@meridianlabs.example answers within a business day.

Nora

', }; } diff --git a/public/demo/beacon-launch-deck.pptx b/public/demo/beacon-launch-deck.pptx new file mode 100644 index 000000000..4f4c44ec0 Binary files /dev/null and b/public/demo/beacon-launch-deck.pptx differ diff --git a/public/demo/beacon-launch-plan-v3.pdf b/public/demo/beacon-launch-plan-v3.pdf new file mode 100644 index 000000000..0fd9d3235 Binary files /dev/null and b/public/demo/beacon-launch-plan-v3.pdf differ diff --git a/public/demo/beacon-rollback-runbook.docx b/public/demo/beacon-rollback-runbook.docx new file mode 100644 index 000000000..03fe33f34 Binary files /dev/null and b/public/demo/beacon-rollback-runbook.docx differ diff --git a/public/demo/beacon-soak-dashboard.png b/public/demo/beacon-soak-dashboard.png new file mode 100644 index 000000000..8ea1d0dc8 Binary files /dev/null and b/public/demo/beacon-soak-dashboard.png differ diff --git a/public/demo/beacon-soak-metrics.xlsx b/public/demo/beacon-soak-metrics.xlsx new file mode 100644 index 000000000..4e6b4e90a Binary files /dev/null and b/public/demo/beacon-soak-metrics.xlsx differ diff --git a/public/demo/boarding-pass-ber-lis.pdf b/public/demo/boarding-pass-ber-lis.pdf new file mode 100644 index 000000000..d0e5b80ef Binary files /dev/null and b/public/demo/boarding-pass-ber-lis.pdf differ diff --git a/public/demo/book-cover-draft.png b/public/demo/book-cover-draft.png new file mode 100644 index 000000000..3d30914fb Binary files /dev/null and b/public/demo/book-cover-draft.png differ diff --git a/public/demo/budget-2026.xlsx b/public/demo/budget-2026.xlsx new file mode 100644 index 000000000..3642efdfb Binary files /dev/null and b/public/demo/budget-2026.xlsx differ diff --git a/public/demo/hetzner-invoice.pdf b/public/demo/hetzner-invoice.pdf new file mode 100644 index 000000000..610081e88 Binary files /dev/null and b/public/demo/hetzner-invoice.pdf differ diff --git a/public/demo/hotel-voucher-lisbon.pdf b/public/demo/hotel-voucher-lisbon.pdf new file mode 100644 index 000000000..cdc2dd920 Binary files /dev/null and b/public/demo/hotel-voucher-lisbon.pdf differ diff --git a/public/demo/infra-summit-ticket.pdf b/public/demo/infra-summit-ticket.pdf new file mode 100644 index 000000000..b8f68d3d8 Binary files /dev/null and b/public/demo/infra-summit-ticket.pdf differ diff --git a/public/demo/invoice-designco-2026-089.pdf b/public/demo/invoice-designco-2026-089.pdf new file mode 100644 index 000000000..9aa3b8853 Binary files /dev/null and b/public/demo/invoice-designco-2026-089.pdf differ diff --git a/public/demo/meeting-notes.md b/public/demo/meeting-notes.md new file mode 100644 index 000000000..53e5f2462 --- /dev/null +++ b/public/demo/meeting-notes.md @@ -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. diff --git a/public/demo/po-lumen-logistics.pdf b/public/demo/po-lumen-logistics.pdf new file mode 100644 index 000000000..6cbd05c27 Binary files /dev/null and b/public/demo/po-lumen-logistics.pdf differ diff --git a/public/demo/portraits/ahmed-hassan.jpg b/public/demo/portraits/ahmed-hassan.jpg new file mode 100644 index 000000000..ab7d0cf88 Binary files /dev/null and b/public/demo/portraits/ahmed-hassan.jpg differ diff --git a/public/demo/portraits/alice-johnson.jpg b/public/demo/portraits/alice-johnson.jpg new file mode 100644 index 000000000..a25b2cde1 Binary files /dev/null and b/public/demo/portraits/alice-johnson.jpg differ diff --git a/public/demo/portraits/anna-kowalski.jpg b/public/demo/portraits/anna-kowalski.jpg new file mode 100644 index 000000000..891366815 Binary files /dev/null and b/public/demo/portraits/anna-kowalski.jpg differ diff --git a/public/demo/portraits/bob-chen.jpg b/public/demo/portraits/bob-chen.jpg new file mode 100644 index 000000000..8cfdc9bec Binary files /dev/null and b/public/demo/portraits/bob-chen.jpg differ diff --git a/public/demo/portraits/carlos-rivera.jpg b/public/demo/portraits/carlos-rivera.jpg new file mode 100644 index 000000000..9bda45e9b Binary files /dev/null and b/public/demo/portraits/carlos-rivera.jpg differ diff --git a/public/demo/portraits/daniel-cooper.jpg b/public/demo/portraits/daniel-cooper.jpg new file mode 100644 index 000000000..33b35b333 Binary files /dev/null and b/public/demo/portraits/daniel-cooper.jpg differ diff --git a/public/demo/portraits/david-park.jpg b/public/demo/portraits/david-park.jpg new file mode 100644 index 000000000..3c3ea1176 Binary files /dev/null and b/public/demo/portraits/david-park.jpg differ diff --git a/public/demo/portraits/elias-lindqvist.jpg b/public/demo/portraits/elias-lindqvist.jpg new file mode 100644 index 000000000..e4df869d4 Binary files /dev/null and b/public/demo/portraits/elias-lindqvist.jpg differ diff --git a/public/demo/portraits/emma-wilson.jpg b/public/demo/portraits/emma-wilson.jpg new file mode 100644 index 000000000..8f83997e1 Binary files /dev/null and b/public/demo/portraits/emma-wilson.jpg differ diff --git a/public/demo/portraits/james-miller.jpg b/public/demo/portraits/james-miller.jpg new file mode 100644 index 000000000..3388560ea Binary files /dev/null and b/public/demo/portraits/james-miller.jpg differ diff --git a/public/demo/portraits/jennifer-hayes.jpg b/public/demo/portraits/jennifer-hayes.jpg new file mode 100644 index 000000000..14d1053ad Binary files /dev/null and b/public/demo/portraits/jennifer-hayes.jpg differ diff --git a/public/demo/portraits/kenji-watanabe.jpg b/public/demo/portraits/kenji-watanabe.jpg new file mode 100644 index 000000000..64d510004 Binary files /dev/null and b/public/demo/portraits/kenji-watanabe.jpg differ diff --git a/public/demo/portraits/lena-hoffmann.jpg b/public/demo/portraits/lena-hoffmann.jpg new file mode 100644 index 000000000..3ad20a300 Binary files /dev/null and b/public/demo/portraits/lena-hoffmann.jpg differ diff --git a/public/demo/portraits/lisa-tanaka.jpg b/public/demo/portraits/lisa-tanaka.jpg new file mode 100644 index 000000000..5d8dbbde8 Binary files /dev/null and b/public/demo/portraits/lisa-tanaka.jpg differ diff --git a/public/demo/portraits/marcus-hughes.jpg b/public/demo/portraits/marcus-hughes.jpg new file mode 100644 index 000000000..81ef6b93a Binary files /dev/null and b/public/demo/portraits/marcus-hughes.jpg differ diff --git a/public/demo/portraits/maria-lopez.jpg b/public/demo/portraits/maria-lopez.jpg new file mode 100644 index 000000000..6636e594b Binary files /dev/null and b/public/demo/portraits/maria-lopez.jpg differ diff --git a/public/demo/portraits/michael-torres.jpg b/public/demo/portraits/michael-torres.jpg new file mode 100644 index 000000000..9461d7273 Binary files /dev/null and b/public/demo/portraits/michael-torres.jpg differ diff --git a/public/demo/portraits/nora-lindqvist.jpg b/public/demo/portraits/nora-lindqvist.jpg new file mode 100644 index 000000000..a3bc7c436 Binary files /dev/null and b/public/demo/portraits/nora-lindqvist.jpg differ diff --git a/public/demo/portraits/olivia-bennett.jpg b/public/demo/portraits/olivia-bennett.jpg new file mode 100644 index 000000000..8baaec881 Binary files /dev/null and b/public/demo/portraits/olivia-bennett.jpg differ diff --git a/public/demo/portraits/priya-sharma.jpg b/public/demo/portraits/priya-sharma.jpg new file mode 100644 index 000000000..562a116a7 Binary files /dev/null and b/public/demo/portraits/priya-sharma.jpg differ diff --git a/public/demo/portraits/rachel-green.jpg b/public/demo/portraits/rachel-green.jpg new file mode 100644 index 000000000..50f84f39c Binary files /dev/null and b/public/demo/portraits/rachel-green.jpg differ diff --git a/public/demo/portraits/sarah-kim.jpg b/public/demo/portraits/sarah-kim.jpg new file mode 100644 index 000000000..9223e5568 Binary files /dev/null and b/public/demo/portraits/sarah-kim.jpg differ diff --git a/public/demo/portraits/sofia-russo.jpg b/public/demo/portraits/sofia-russo.jpg new file mode 100644 index 000000000..3eaced3e0 Binary files /dev/null and b/public/demo/portraits/sofia-russo.jpg differ diff --git a/public/demo/portraits/tomas-ferreira.jpg b/public/demo/portraits/tomas-ferreira.jpg new file mode 100644 index 000000000..ddf2c1f8c Binary files /dev/null and b/public/demo/portraits/tomas-ferreira.jpg differ diff --git a/public/demo/portraits/wei-zhang.jpg b/public/demo/portraits/wei-zhang.jpg new file mode 100644 index 000000000..67e16a849 Binary files /dev/null and b/public/demo/portraits/wei-zhang.jpg differ diff --git a/public/demo/portraits/yusuf-demir.jpg b/public/demo/portraits/yusuf-demir.jpg new file mode 100644 index 000000000..1557d859a Binary files /dev/null and b/public/demo/portraits/yusuf-demir.jpg differ diff --git a/public/demo/quarterly-report.pdf b/public/demo/quarterly-report.pdf new file mode 100644 index 000000000..e34b678f0 Binary files /dev/null and b/public/demo/quarterly-report.pdf differ diff --git a/public/demo/service-agreement-draft.docx b/public/demo/service-agreement-draft.docx new file mode 100644 index 000000000..11c349064 Binary files /dev/null and b/public/demo/service-agreement-draft.docx differ diff --git a/public/demo/todo.txt b/public/demo/todo.txt new file mode 100644 index 000000000..a3ce819bc --- /dev/null +++ b/public/demo/todo.txt @@ -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 diff --git a/scripts/demo-assets/generate.mjs b/scripts/demo-assets/generate.mjs new file mode 100644 index 000000000..ace1f8316 --- /dev/null +++ b/scripts/demo-assets/generate.mjs @@ -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) => `
${body}
`; +const logo = (name, color) => ``; + +// 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(` +
${logo('DesignCo', '#0f8578')}
DesignCo Studio GmbH
Torstraße 112, 10119 Berlin
hello@designco.example
+

Invoice 2026-089

+
+
Billed to
Meridian Labs GmbH
Attn. Nora Lindqvist
Rosenthaler Str. 40, 10178 Berlin
+
Details
Issued ${fmt(now)}
Due ${fmt(plus(14))}
Project: Website redesign
+
+ + + + + + +
DescriptionHoursRateAmount
Landing page prototype, v3 (hero option B, asymmetric grid)24€110.00€2,640.00
Design system tokens and component audit12€110.00€1,320.00
Two review calls and revisions4€110.00€440.00
Subtotal€4,400.00
VAT 19%€836.00
Total due€5,236.00
+

Payment

Bank transfer to DesignCo Studio GmbH, IBAN DE02 1203 0000 0000 2020 51, reference 2026-089. The ${nextMonth} pre-payment has been backed out as agreed.

+
DesignCo Studio GmbH · HRB 98765 B · VAT DE 812 345 678Page 1 of 1
`), + + // Hotel voucher for the Lisbon trip. + 'hotel-voucher-lisbon.pdf': page(` +
${logo('Booking.com', '#003580')}Confirmed
+

Hotel Tejo Vista, Lisbon

+

Rua do Alecrim 48, 1200-018 Lisboa, Portugal · +351 21 555 0170

+
+
Check-in
${plus(9).getDate()}
${plus(9).toLocaleDateString('en-GB', { weekday: 'long', month: 'long' })}
from 15:00
+
Check-out
${plus(12).getDate()}
${plus(12).toLocaleDateString('en-GB', { weekday: 'long', month: 'long' })}
until 11:00
+
+ + + +
RoomNightsPrice
Superior double, river view, breakfast included3€486.00
City tax (paid at the property)€12.00
Paid€486.00
+

Confirmation

Number 4892.771.433 · PIN 2047. Show this voucher at reception. Free cancellation ended ${fmt(plus(2))}.

+
Guest: Nora Lindqvist · 1 adultBooking.com B.V.
`), + + // Boarding pass BER → LIS. + 'boarding-pass-ber-lis.pdf': page(` +
${logo('Lufthansa', '#05164d')}Boarding pass
+
+
+
Berlin
BER
+
→
+
Lisbon
LIS
+
+
+
Flight
LH 1796
+
Date
${fmt(plus(9))}
+
Boarding
07:55
+
Departure
08:35
+
Passenger
LINDQVIST / NORA
+
Seat
14A
+
Gate
B21
+
Class
Economy
+
+
+
M1LINDQVIST/NORA E7KQ2R BERLISLH 1796 ${String(plus(9).getDate()).padStart(2, '0')}Y014A0031 100
+
+

Be at the gate 30 minutes before departure. Hand baggage: 1 piece up to 8 kg.

+
Booking reference E7KQ2R · Miles & More 992 004 118 221Deutsche Lufthansa AG
`), + + // Conference ticket. + 'infra-summit-ticket.pdf': page(` +
${logo('Infra Summit Lisbon', '#db2d54')}Speaker
+

Nora Lindqvist

+

Meridian Labs · Talk: "Rolling back without rolling over: safe launches for event platforms"

+
+
When
${fmt(plus(10))} to ${fmt(plus(11))}
Doors 08:30, talks from 09:30
+
Where
Centro de Congressos de Lisboa
Praça das Indústrias 1, Lisboa
+
+
+
+
Ticket
IS26-SPK-01827
Includes the speakers' dinner on day one and the workshop day.
+
+
Issued by Infra Summit Lda. via TitoNon-transferable
`), + + // Beacon launch plan (the PDF in the Projects folder). + 'beacon-launch-plan-v3.pdf': page(` +
${logo('Meridian Labs', '#db2d54')}Internal · v3
+

Beacon launch plan

+

Owner: Michael Torres · Engineering: Alice Johnson, Nora Lindqvist · Launch ${fmt(plus(8))}

+

What ships

+

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}.

+

Timeline

+ + + + + +
DayMilestoneOwnerState
${fmt(plus(-6))}Load test at 3× peak, 48 h soakAhmeddone
${fmt(plus(1))}Go / no-go reviewAlicescheduled
${fmt(plus(3))}Rollback runbook signed offNorain review
${fmt(plus(6))}Canary: 5% of tenants, 24 hAhmed, Priyaplanned
${fmt(plus(8))}General availability, blog post, changelogRachelplanned
+

Risks

+

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.

+
Meridian Labs GmbH · ConfidentialPage 1 of 1
`), + + // Hetzner invoice in Receipts. + 'hetzner-invoice.pdf': page(` +
${logo('Hetzner', '#d50c2d')}
Hetzner Online GmbH
Industriestr. 25, 91710 Gunzenhausen
+

Invoice R0012345678

+

Customer K0987654 · Meridian Labs GmbH · Period ${month} ${year}

+ + + + + + +
ProductLocationAmount
Cloud server CAX31 (beacon-eu-1)Falkenstein€14.69
Cloud server CAX31 (beacon-eu-2)Helsinki€14.69
Load balancer LB11Falkenstein€5.83
Object storage, 412 GBFalkenstein€8.24
VAT 19%€8.26
Total (paid by SEPA direct debit)€51.71
+
Hetzner Online GmbH · VAT DE 812 871 812Page 1 of 1
`), + + // Quarterly report in Files. + 'quarterly-report.pdf': page(` +
${logo('Meridian Labs', '#db2d54')}Board update
+

Quarterly report

+

Engineering and product · prepared by Nora Lindqvist and Michael Torres

+
+
Uptime
99.97%
+
p95 latency
184 ms
+
Active tenants
1,284
+
+

Highlights

+

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.

+

Next quarter

+

Beacon general availability, migration tooling for long-poll customers, and the first external audit of the delivery signing scheme.

+
Meridian Labs GmbH · ConfidentialPage 1 of 1
`), + + // Internal purchase order attached to the support thread. + 'po-lumen-logistics.pdf': page(` +
${logo('Lumen Logistics', '#1d4ed8')}Purchase order
+

PO 2026-0412

+

Lumen Logistics S.A. · Av. da Liberdade 110, Lisboa · to Meridian Labs GmbH

+ + + +
ItemQtyAmount
Meridian Platform, Business plan, annual1€11,880.00
Beacon add-on, 50 tenants1€3,600.00
Total excl. VAT€15,480.00
+

Please invoice to accounts@lumenlogistics.example and quote the PO number. Payment terms 30 days.

+
Approved: Tomás Ferreira, Head of OperationsPage 1 of 1
`), +}; + +// 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: ` +
Beacon · soak test, 48 h at 3× peakeu-1, eu-2 · last 48 hours
+
+
Deliveries / s42,380
+
p95 delivery latency184 ms
+
Retry budget used11%
+
Errors0.02%
+
+
Deliveries per second + + + ${[0.25, 0.5, 0.75].map((f) => ``).join('')} + + +
+ `, + }, + // Illustration draft from Daniel Cooper (the commission thread). + 'book-cover-draft.png': { + width: 900, height: 1200, html: ` +
+
+

The Lemon
Tree Year

Sofia Russo

+
draft 2 — bolder sun?
+ `, + }, +}; + +// 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}`); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 9d6723174..b111eaaa0 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -1003,6 +1003,9 @@ let refreshPromise: Promise | null = null; // Multi-account state: per-account JMAP clients and refresh timers const clients = new Map(); +/** 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()( 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, 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()( 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; } diff --git a/stores/policy-store.ts b/stores/policy-store.ts index 5d0003492..d0bfb3a04 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -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; + setDemoFeatureOverrides: (overrides: Partial) => void; fetchPolicy: () => Promise; /** Fetch the full policy if only the public subset is loaded. */ refreshIfPartial: () => Promise; @@ -42,6 +49,9 @@ export const usePolicyStore = create()((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()((set, get) => ({ }, isFeatureEnabled: (feature) => { + const override = get().demoFeatureOverrides[feature]; + if (override !== undefined) return override; return get().policy.features[feature] ?? true; },