diff --git a/lib/demo/__tests__/fixtures.test.ts b/lib/demo/__tests__/fixtures.test.ts index 1b3ccc415..97462c946 100644 --- a/lib/demo/__tests__/fixtures.test.ts +++ b/lib/demo/__tests__/fixtures.test.ts @@ -32,6 +32,7 @@ import { checkAvailabilityAccess, lookUpAvailability } from '@/lib/jmap/free-bus import { availabilityFor } from '@/components/calendar/participant-availability'; import { demoAt } from '../demo-utils'; import type { BusyPeriod } from '@/lib/jmap/types'; +import { useRecoveryStore } from '@/stores/recovery-store'; const PUBLIC = path.resolve(__dirname, '..', '..', '..', 'public'); const work = getDemoData('work'); @@ -433,4 +434,33 @@ describe('demo fixtures: audience screenshots', () => { const weekly = (id: string) => availabilityFor((answers[id] as { periods: BusyPeriod[] }).periods, new Date(demoAt(0, 10)), new Date(demoAt(0, 11))); expect(ids.map(weekly)).toEqual(['busy', 'busy', 'busy']); }); + + it('Recover deleted items lists four deleted messages, newest first, and puts one back in the Inbox', async () => { + const client = new DemoJMAPClient('work'); + // What stores/recovery-store.ts asks before Settings shows the page. + expect(await useRecoveryStore.getState().detect(client, 'fixture-test|demo-account', { force: true })).toBe(true); + expect(await client.hasArchivedEmail!(undefined, { days: 30 })).toBe(true); + const { items, nextPosition } = await client.listArchivedEmails!(undefined, { days: 30 }); + expect(nextPosition).toBeNull(); + expect(items.map((i) => i.subject)).toEqual([ + 'Beacon budget v2: infra line items', + 'Canary tenants: final list', + 'Infra Weekly #212: rollbacks, retries and one very long postmortem', + 'Launch post: first draft', + ]); + expect(items[0]).toMatchObject({ fromName: PRIYA.name, fromEmail: PRIYA.email, accountId: client.getAccountId() }); + // None of them is still in a folder. + const known = new Set(work.emails.map((e) => e.messageId)); + for (const item of items) expect(known.has(`<${item.id.replace('archived-', '')}@meridianlabs.example>`), item.subject).toBe(false); + + const [result] = await client.restoreArchivedItems!([items[0].id]); + expect(result.ok).toBe(true); + const { emails } = await client.getEmails(MB.inbox); + const back = emails.find((e) => e.subject === items[0].subject)!; + expect(back).toBeDefined(); + expect(back.keywords).toEqual({}); + expect((await client.listArchivedEmails!(undefined, { days: 30 })).items).toHaveLength(3); + expect((await client.restoreArchivedItems!([items[0].id]))[0]).toMatchObject({ ok: false, type: 'notFound' }); + expect(new DemoJMAPClient('personal').advertisesArchivedItems!()).toBe(false); + }); }); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 9dbd1da84..d76e11f3c 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -7,6 +7,11 @@ import { createDemoPrincipals } from './fixtures/directory'; import { createDemoBusyBlocks } from './fixtures/availability'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { createDemoArchivedItems, type DemoArchivedItem } from './fixtures/archive'; +import { + hasArchivedEmail as hasArchivedEmailIn, listArchivedEmailsPaged, + type ArchiveIo, type ArchiveListResult, type ArchiveQueryPage, type RestoreResult, +} from '@/lib/recovery/archived-items'; import { getDemoData, demoSharedAccounts, type DemoData, type DemoProfile } from './demo-data'; import { generateDemoId } from './demo-utils'; import { DEMO_ASSETS } from './fixtures/assets'; @@ -51,6 +56,8 @@ export class DemoJMAPClient implements IJMAPClient { private readonly profile: DemoProfile; /** The server's directory: the Meridian Labs cast for the work login, nobody for the personal one. */ private readonly principals: Principal[]; + /** Mail deleted for good that the server still keeps (Recover deleted items). */ + private archive: DemoArchivedItem[]; private blobStore = new Map(); private scheduledSubmissions = new Map(); private connectionCallback: ((connected: boolean) => void) | null = null; @@ -62,6 +69,7 @@ export class DemoJMAPClient implements IJMAPClient { this.profile = profile; this.data = getDemoData(profile); this.principals = profile === 'personal' ? [] : createDemoPrincipals(); + this.archive = profile === 'personal' ? [] : createDemoArchivedItems(this.data.accountId); // 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) { @@ -1745,6 +1753,77 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } + // ── Recover deleted items (x:ArchivedItem, lib/recovery/archived-items.ts) ── + // As on Stalwart Enterprise with archiveDeletedItemsFor set: the work + // account keeps what Nora deleted for good (fixtures/archive.ts). + + advertisesArchivedItems(accountId?: string): boolean { + return !this.isPersonal && (!accountId || accountId === this.data.accountId); + } + + /** x:ArchivedItem/query: newest first, paged; `total` with calculateTotal. */ + async queryArchivedItemIds(accountId?: string, options: { position?: number; limit?: number; calculateTotal?: boolean } = {}): Promise { + const id = accountId || this.data.accountId; + if (!this.advertisesArchivedItems(id)) { + throw Object.assign(new Error('Archived items are not available for this account'), { type: 'forbidden' }); + } + const ids = this.archive + .filter((a) => a.item.accountId === id) + .sort((a, b) => b.item.archivedAt.localeCompare(a.item.archivedAt)) + .map((a) => a.item.id); + const position = options.position ?? 0; + return { + ids: ids.slice(position, options.limit ? position + options.limit : undefined), + total: options.calculateTotal ? ids.length : null, + }; + } + + private archiveIo(accountId: string): ArchiveIo { + return { + query: (position, limit) => this.queryArchivedItemIds(accountId, { position, limit, calculateTotal: true }), + // x:ArchivedItem/get + get: async (ids) => ids.flatMap((id) => { + const found = this.archive.find((a) => a.item.id === id); + return found ? [structuredClone(found.item)] : []; + }), + }; + } + + async listArchivedEmails(accountId?: string, options: { days?: number; startPosition?: number } = {}): Promise { + const id = accountId || this.data.accountId; + return listArchivedEmailsPaged(this.archiveIo(id), id, { days: options.days ?? 0, startPosition: options.startPosition }); + } + + async hasArchivedEmail(accountId?: string, options: { days?: number } = {}): Promise { + const id = accountId || this.data.accountId; + return hasArchivedEmailIn(this.archiveIo(id), id, { days: options.days ?? 0 }); + } + + /** + * x:ArchivedItem/set `{ status: "requestRestore" }`: the item leaves the + * archive and the message is back in the Inbox, unread and without keywords. + */ + async restoreArchivedItems(ids: string[], accountId?: string): Promise { + const id = accountId || this.data.accountId; + const results = ids.map((itemId): RestoreResult => { + const index = this.archive.findIndex((a) => a.item.id === itemId && a.item.accountId === id); + if (index < 0) return { id: itemId, ok: false, error: 'notFound', type: 'notFound' }; + const [{ email }] = this.archive.splice(index, 1); + const restored = structuredClone(email); + this.deriveHeaderFields(restored); + this.data.emails.push(withEmlBlob(restored)); + return { id: itemId, ok: true }; + }); + if (results.some((r) => r.ok)) { + this.recalcMailboxCounts(); + this.stateChangeCallback?.({ + '@type': 'StateChange', + changed: { [this.data.accountId]: { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } }, + }); + } + return results; + } + // ── Internal helpers ────────────────────────────────────────── private recalcMailboxCounts(): void { diff --git a/lib/demo/fixtures/archive.ts b/lib/demo/fixtures/archive.ts new file mode 100644 index 000000000..c8f21a78a --- /dev/null +++ b/lib/demo/fixtures/archive.ts @@ -0,0 +1,89 @@ +/** + * Mail Nora deleted for good (emptied from Trash) that the server still + * keeps: Stalwart Enterprise's archive of deleted items, which Settings > + * Recover deleted items lists and restores (lib/recovery/archived-items.ts). + * + * Each entry is the x:ArchivedItem the server answers with (`@type`, + * `from` as "Name
", `archivedAt` when it was deleted for good, + * `archivedUntil` when the server drops it) and the message a restore puts + * back in the Inbox: unread, without keywords, under its own date. + */ +import type { Email } from '@/lib/jmap/types'; +import { at, authPass, listHeaders, mail } from './mail'; +import { MB } from './mailboxes'; +import { addr, BOB, LISA, PRIYA, WORK_DOMAIN } from './people'; + +export interface DemoArchivedItem { + /** The x:ArchivedItem/get record. */ + item: { + '@type': 'Email'; + id: string; + accountId: string; + from: string; + subject: string; + receivedAt: string; + archivedAt: string; + archivedUntil: string; + size: number; + blobId: string; + }; + /** The message as it comes back. */ + email: Email; +} + +/** How long the server keeps deleted mail (Stalwart's archiveDeletedItemsFor). */ +const KEEP_DAYS = 30; + +export function createDemoArchivedItems(accountId: string): DemoArchivedItem[] { + const entry = (archivedDaysAgo: number, archivedHour: number, email: Email): DemoArchivedItem => { + const from = email.from![0]; + const archivedAt = at(archivedDaysAgo, archivedHour, 12); + return { + item: { + '@type': 'Email', + id: `archived-${email.id}`, + accountId, + from: `${from.name} <${from.email}>`, + subject: email.subject ?? '', + receivedAt: email.receivedAt, + archivedAt, + archivedUntil: new Date(new Date(archivedAt).getTime() + KEEP_DAYS * 86_400_000).toISOString(), + size: email.size, + blobId: `archived-blob-${email.id}`, + }, + // Back in the Inbox, unread and without keywords, as the restore task leaves it. + email: { ...email, mailboxIds: { [MB.inbox]: true }, keywords: {} }, + }; + }; + + return [ + entry(1, 18, mail({ + id: 'demo-archived-budget-v2', mailbox: MB.inbox, date: at(3, 14, 5), + from: addr(PRIYA), + subject: 'Beacon budget v2: infra line items', + text: 'Nora,\n\nUpdated the infra part of the Beacon budget after the soak test. The eu-2 replicas stay at three, so the monthly line goes from 4,200 to 5,100 EUR. Everything else is unchanged. The sheet is in Documents/budget-2026.xlsx, tab "Beacon".\n\nCan you sign this off before the go/no-go? Finance wants it by the end of the week.\n\nPriya', + headers: authPass(WORK_DOMAIN), + })), + entry(2, 9, mail({ + id: 'demo-archived-canary-list', mailbox: MB.inbox, date: at(4, 16, 40), + from: addr(BOB), + subject: 'Canary tenants: final list', + text: 'The five canary tenants for the 5% step, all agreed with their account managers:\n\n1. Lumen Logistics\n2. Hoffmann & Partner\n3. Demir Retail\n4. Northwind\n5. Cooper Creative\n\nThey all have webhooks on and more than 1,000 deliveries a day, so we see problems fast.\n\nBob', + headers: authPass(WORK_DOMAIN), + })), + entry(3, 11, mail({ + id: 'demo-archived-infra-weekly', mailbox: MB.inbox, date: at(5, 6, 0), + from: { name: 'Infra Weekly', email: 'digest@infraweekly.example' }, + subject: 'Infra Weekly #212: rollbacks, retries and one very long postmortem', + text: 'Infra Weekly #212\n\n1. Rolling back without rolling over: notes from three teams that roll back by numbers, not by feeling.\n2. Retry budgets in practice.\n3. The postmortem of the week is 40 pages long and worth all of them.\n\nManage your subscription at infraweekly.example/manage.', + headers: { ...authPass('infraweekly.example'), ...listHeaders('Infra Weekly', 'infraweekly.example') }, + })), + entry(6, 15, mail({ + id: 'demo-archived-blog-draft', mailbox: MB.inbox, date: at(8, 10, 20), + from: addr(LISA), + subject: 'Launch post: first draft', + text: 'Hi Nora,\n\nFirst draft of the Beacon launch post is in the shared doc. I kept the technical part short and linked the docs instead. Could you check the paragraph on retries? I am not sure I got the budget right.\n\nLisa', + headers: authPass(WORK_DOMAIN), + })), + ]; +}