import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode } from '@/lib/jmap/types'; import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; import { getDemoData, type DemoData } from './demo-data'; import { generateDemoId } from './demo-utils'; /** * In-memory JMAP client for demo mode. * All data lives in memory - no network calls, no cookies. */ export class DemoJMAPClient implements IJMAPClient { private data: DemoData; private blobStore = new Map(); private connectionCallback: ((connected: boolean) => void) | null = null; private stateChangeCallback: ((change: StateChange) => void) | null = null; private lastStates: AccountStates = {}; private incomingTimer: ReturnType | null = null; constructor() { this.data = getDemoData(); } // ── Connection lifecycle ────────────────────────────────────── async connect(): Promise { // Start simulated incoming email timer this.startIncomingEmailTimer(); } disconnect(): void { this.stopIncomingEmailTimer(); this.connectionCallback = null; this.stateChangeCallback = null; } async reconnect(): Promise { /* no-op */ } async ping(): Promise { /* no-op */ } // ── Session / auth accessors ────────────────────────────────── getServerUrl(): string { return 'https://demo.example.com'; } getAuthHeader(): string { return 'Bearer demo-token'; } updateAccessToken(): void { /* no-op */ } upgradeToBearer(): void { /* no-op */ } enableTotpReauth(): void { /* no-op */ } updateBasicAuth(): void { /* no-op */ } getAccountId(): string { return 'demo-account'; } getUsername(): string { return 'demo@example.com'; } // ── Capabilities ────────────────────────────────────────────── hasAccountCapability(_capability: string, _accountId?: string): boolean { return false; } getCapabilities(): Record { return { 'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:submission': {}, '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': {}, }; } getMaxSizeUpload(): number { return 50_000_000; } getMaxCallsInRequest(): number { return 16; } getMaxObjectsInGet(): number { return 500; } getEventSourceUrl(): string | null { return null; } supportsEmailSubmission(): boolean { return true; } supportsQuota(): boolean { return true; } supportsVacationResponse(): boolean { return true; } supportsContacts(): boolean { return true; } supportsCalendars(): boolean { return true; } supportsSieve(): boolean { return true; } supportsFiles(): boolean { return true; } supportsPrincipals(): boolean { return false; } async getPrincipals(): Promise { return []; } async setCalendarShare(): Promise { /* demo: no-op */ } async setAddressBookShare(): Promise { /* demo: no-op */ } // ── Push / state ────────────────────────────────────────────── setupPushNotifications(): boolean { return true; } closePushNotifications(): void { /* no-op in demo */ } onConnectionChange(callback: (connected: boolean) => void): void { this.connectionCallback = callback; } onRateLimit(): void { /* no-op in demo */ } isRateLimited(): boolean { return false; } getRateLimitRemainingMs(): number { return 0; } onStateChange(callback: (change: StateChange) => void): void { this.stateChangeCallback = callback; } getLastStates(): AccountStates { return { ...this.lastStates }; } setLastStates(states: AccountStates): void { this.lastStates = { ...states }; } // ── Quota ───────────────────────────────────────────────────── async getQuota(): Promise<{ used: number; total: number } | null> { return { used: 245_366_784, total: 1_073_741_824 }; } // ── Mailboxes ───────────────────────────────────────────────── async getMailboxes(): Promise { return [...this.data.mailboxes]; } async getAllMailboxes(): Promise { return [...this.data.mailboxes]; } async createMailbox(name: string, parentId?: string): Promise { const mb: Mailbox = { id: generateDemoId('mailbox'), name, sortOrder: 100, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, parentId, isSubscribed: true, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true }, }; this.data.mailboxes.push(mb); return mb; } async updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise { const mb = this.data.mailboxes.find(m => m.id === mailboxId); if (mb) Object.assign(mb, changes); } async deleteMailbox(mailboxId: string): Promise { this.data.mailboxes = this.data.mailboxes.filter(m => m.id !== mailboxId); // Also remove emails in this mailbox this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]); } // ── Emails ──────────────────────────────────────────────────── async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> { let filtered = this.data.emails; if (mailboxId) { filtered = filtered.filter(e => e.mailboxIds[mailboxId]); } filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); const total = filtered.length; const emails = filtered.slice(position, position + limit); return { emails, hasMore: position + limit < total, total }; } async getEmailsInMailbox(mailboxId: string): Promise { return this.data.emails.filter(e => e.mailboxIds[mailboxId]); } async getEmail(emailId: string): Promise { return this.data.emails.find(e => e.id === emailId) ?? null; } async getTagCounts(tagIds: string[]): Promise> { const result: Record = {}; for (const tagId of tagIds) { const tagged = this.data.emails.filter(e => e.keywords[tagId]); result[tagId] = { total: tagged.length, unread: tagged.filter(e => !e.keywords.$seen).length, }; } return result; } async searchEmails(query: string, mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> { const q = query.toLowerCase(); let filtered = this.data.emails.filter(e => { const text = [e.subject, e.preview, e.from?.[0]?.name, e.from?.[0]?.email].filter(Boolean).join(' ').toLowerCase(); return text.includes(q); }); if (mailboxId) filtered = filtered.filter(e => e.mailboxIds[mailboxId]); filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); const total = filtered.length; const emails = filtered.slice(position, position + limit); return { emails, hasMore: position + limit < total, total }; } async advancedSearchEmails(filter: Record, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> { // Simplified: just return all emails for any advanced filter let filtered = [...this.data.emails]; if (filter.inMailbox) filtered = filtered.filter(e => e.mailboxIds[filter.inMailbox as string]); if (filter.text) { const q = (filter.text as string).toLowerCase(); filtered = filtered.filter(e => [e.subject, e.preview].filter(Boolean).join(' ').toLowerCase().includes(q)); } filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()); const total = filtered.length; const emails = filtered.slice(position, position + limit); return { emails, hasMore: position + limit < total, total }; } // ── Email mutations ─────────────────────────────────────────── async markAsRead(emailId: string, read: boolean = true): Promise { const email = this.data.emails.find(e => e.id === emailId); if (!email) return; if (read) { email.keywords.$seen = true; } else { delete email.keywords.$seen; } this.recalcMailboxCounts(); } async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise { for (const id of emailIds) { const email = this.data.emails.find(e => e.id === id); if (email) { if (read) email.keywords.$seen = true; else delete email.keywords.$seen; } } this.recalcMailboxCounts(); } async toggleStar(emailId: string, starred: boolean): Promise { const email = this.data.emails.find(e => e.id === emailId); if (!email) return; if (starred) email.keywords.$flagged = true; else delete email.keywords.$flagged; } async updateEmailKeywords(emailId: string, keywords: Record): Promise { const email = this.data.emails.find(e => e.id === emailId); if (email) email.keywords = { ...email.keywords, ...keywords }; } async setKeyword(emailId: string, keyword: string): Promise { const email = this.data.emails.find(e => e.id === emailId); if (email) email.keywords[keyword] = true; } async migrateKeyword(oldKeyword: string, newKeyword: string): Promise { let count = 0; for (const email of this.data.emails) { if (email.keywords[oldKeyword]) { delete email.keywords[oldKeyword]; email.keywords[newKeyword] = true; count++; } } return count; } async deleteEmail(emailId: string): Promise { this.data.emails = this.data.emails.filter(e => e.id !== emailId); this.recalcMailboxCounts(); } async moveToTrash(emailId: string, trashMailboxId: string): Promise { const email = this.data.emails.find(e => e.id === emailId); if (!email) return; email.mailboxIds = { [trashMailboxId]: true }; this.recalcMailboxCounts(); } async batchDeleteEmails(emailIds: string[]): Promise { const idSet = new Set(emailIds); this.data.emails = this.data.emails.filter(e => !idSet.has(e.id)); this.recalcMailboxCounts(); } async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise { for (const id of emailIds) { const email = this.data.emails.find(e => e.id === id); if (email) email.mailboxIds = { [toMailboxId]: true }; } this.recalcMailboxCounts(); } async batchArchiveEmails( emails: Array<{ id: string; receivedAt: string }>, archiveMailboxId: string, mode: 'single' | 'year' | 'month', ): Promise { if (emails.length === 0) return; if (mode === 'single') { await this.batchMoveEmails(emails.map(e => e.id), archiveMailboxId); return; } for (const { id, receivedAt } of emails) { const email = this.data.emails.find(e => e.id === id); if (!email) continue; const d = new Date(receivedAt); const year = d.getFullYear().toString(); const month = (d.getMonth() + 1).toString().padStart(2, '0'); let yearBox = this.data.mailboxes.find(m => m.name === year && m.parentId === archiveMailboxId); if (!yearBox) yearBox = await this.createMailbox(year, archiveMailboxId); let destId = yearBox.id; if (mode === 'month') { let monthBox = this.data.mailboxes.find(m => m.name === month && m.parentId === yearBox!.id); if (!monthBox) monthBox = await this.createMailbox(month, yearBox.id); destId = monthBox.id; } email.mailboxIds = { [destId]: true }; } this.recalcMailboxCounts(); } async moveEmail(emailId: string, toMailboxId: string): Promise { const email = this.data.emails.find(e => e.id === emailId); if (email) email.mailboxIds = { [toMailboxId]: true }; this.recalcMailboxCounts(); } async emptyMailbox(mailboxId: string): Promise { const before = this.data.emails.length; this.data.emails = this.data.emails.filter(e => !e.mailboxIds[mailboxId]); const removed = before - this.data.emails.length; this.recalcMailboxCounts(); return removed; } async markMailboxAsRead(mailboxId: string): Promise { let count = 0; for (const email of this.data.emails) { if (email.mailboxIds[mailboxId] && email.keywords.$seen !== true) { email.keywords.$seen = true; count++; } } this.recalcMailboxCounts(); return count; } async markAllAsRead(excludeMailboxIds: string[] = []): Promise { const excluded = new Set(excludeMailboxIds); let count = 0; for (const email of this.data.emails) { if (email.keywords.$seen === true) continue; const mbIds = Object.keys(email.mailboxIds); const onlyInExcluded = mbIds.length > 0 && mbIds.every(id => excluded.has(id)); if (onlyInExcluded) continue; email.keywords.$seen = true; count++; } this.recalcMailboxCounts(); return count; } async markAsSpam(emailId: string): Promise { const email = this.data.emails.find(e => e.id === emailId); const junkMb = this.data.mailboxes.find(m => m.role === 'junk'); if (email && junkMb) email.mailboxIds = { [junkMb.id]: true }; this.recalcMailboxCounts(); } async undoSpam(emailId: string, originalMailboxId: string): Promise { const email = this.data.emails.find(e => e.id === emailId); if (email) email.mailboxIds = { [originalMailboxId]: true }; this.recalcMailboxCounts(); } // ── Threads ─────────────────────────────────────────────────── async getThread(threadId: string): Promise { const emails = this.data.emails.filter(e => e.threadId === threadId); if (emails.length === 0) return null; return { id: threadId, emailIds: emails.map(e => e.id) }; } async getThreadEmails(threadId: string): Promise { return this.data.emails .filter(e => e.threadId === threadId) .sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime()); } // ── Compose / Send ──────────────────────────────────────────── async createDraft( to: string[], subject: string, body: string, cc?: string[], bcc?: string[], _identityId?: string, _fromEmail?: string, draftId?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, _fromName?: string, ): Promise { const draftsMb = this.data.mailboxes.find(m => m.role === 'drafts'); const id = draftId || generateDemoId('email'); const existing = draftId ? this.data.emails.findIndex(e => e.id === draftId) : -1; const email: Email = { id, threadId: generateDemoId('thread'), mailboxIds: { [draftsMb?.id || 'demo-mailbox-drafts']: true }, keywords: { $seen: true, $draft: true }, size: body.length, receivedAt: new Date().toISOString(), from: [{ name: 'Demo User', email: 'demo@example.com' }], to: to.map(e => ({ email: e })), cc: cc?.map(e => ({ email: e })), bcc: bcc?.map(e => ({ email: e })), subject, sentAt: new Date().toISOString(), preview: body.substring(0, 200), hasAttachment: !!attachments?.length, textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }], htmlBody: [], bodyValues: { '1': { value: body } }, attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })), messageId: `<${id}@demo.example.com>`, }; if (existing >= 0) { this.data.emails[existing] = email; } else { this.data.emails.push(email); } this.recalcMailboxCounts(); return id; } async sendEmail( to: string[], subject: string, body: string, cc?: string[], bcc?: string[], _identityId?: string, _fromEmail?: string, draftId?: string, _fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, ): Promise { // Remove draft if updating if (draftId) { this.data.emails = this.data.emails.filter(e => e.id !== draftId); } const sentMb = this.data.mailboxes.find(m => m.role === 'sent'); const email: Email = { id: generateDemoId('email'), threadId: generateDemoId('thread'), mailboxIds: { [sentMb?.id || 'demo-mailbox-sent']: true }, keywords: { $seen: true }, size: body.length + (htmlBody?.length || 0), receivedAt: new Date().toISOString(), from: [{ name: 'Demo User', email: 'demo@example.com' }], to: to.map(e => ({ email: e })), cc: cc?.map(e => ({ email: e })), bcc: bcc?.map(e => ({ email: e })), subject, sentAt: new Date().toISOString(), preview: body.substring(0, 200), hasAttachment: !!attachments?.length, textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }], htmlBody: htmlBody ? [{ partId: '2', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }] : [], bodyValues: htmlBody ? { '1': { value: body }, '2': { value: htmlBody } } : { '1': { value: body } }, attachments: attachments?.map(a => ({ ...a, partId: generateDemoId('part') })), messageId: `<${generateDemoId('msg')}@demo.example.com>`, }; this.data.emails.push(email); this.recalcMailboxCounts(); } async sendImipReply(): Promise { /* no-op in demo */ } async sendImipInvitation(): Promise { /* no-op in demo */ } async sendImipCancellation(): Promise { /* no-op in demo */ } // ── Blobs ───────────────────────────────────────────────────── async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> { const blobId = generateDemoId('blob'); this.blobStore.set(blobId, file); return { blobId, size: file.size, type: file.type }; } getBlobDownloadUrl(blobId: string): string { return `data:application/octet-stream;demo-blob=${blobId}`; } async fetchBlob(blobId: string): Promise { return this.blobStore.get(blobId) ?? new Blob(['[Demo placeholder content]'], { type: 'text/plain' }); } async fetchBlobAsObjectUrl(blobId: string): Promise { const blob = await this.fetchBlob(blobId); return URL.createObjectURL(blob); } async fetchBlobArrayBuffer(blobId: string): Promise { const blob = await this.fetchBlob(blobId); return blob.arrayBuffer(); } async downloadBlob(blobId: string, name?: string): Promise { const blob = await this.fetchBlob(blobId); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name || 'download'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ── Identities ──────────────────────────────────────────────── async getIdentities(): Promise { return [...this.data.identities]; } async createIdentity( name: string, email: string, replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null, htmlSignature?: string, textSignature?: string, ): Promise { const identity: Identity = { id: generateDemoId('identity'), name, email, replyTo: replyTo ?? undefined, bcc: bcc ?? undefined, htmlSignature: htmlSignature ?? '', textSignature: textSignature ?? '', mayDelete: true, }; this.data.identities.push(identity); return identity; } async updateIdentity(identityId: string, updates: { name?: string; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; htmlSignature?: string; textSignature?: string }): Promise { const identity = this.data.identities.find(i => i.id === identityId); if (identity) Object.assign(identity, updates); } async deleteIdentity(identityId: string): Promise { this.data.identities = this.data.identities.filter(i => i.id !== identityId); } // ── Vacation ────────────────────────────────────────────────── async getVacationResponse(): Promise { return { ...this.data.vacationResponse }; } async setVacationResponse(updates: Partial): Promise { Object.assign(this.data.vacationResponse, updates); } // ── Contacts ────────────────────────────────────────────────── getContactsAccountId(): string { return 'demo-account'; } async getAddressBooks(): Promise { return [...this.data.addressBooks]; } async getAllAddressBooks(): Promise { return [...this.data.addressBooks]; } async createAddressBook(name: string): Promise { const book: AddressBook = { id: `demo-book-${Date.now()}`, name }; this.data.addressBooks.push(book); return book; } async updateAddressBook(addressBookId: string, updates: Partial): Promise { const book = this.data.addressBooks.find(b => b.id === addressBookId); if (book) Object.assign(book, updates); } async deleteAddressBook(addressBookId: string): Promise { this.data.addressBooks = this.data.addressBooks.filter(b => b.id !== addressBookId); this.data.contacts = this.data.contacts.filter(c => !c.addressBookIds?.[addressBookId]); } async getContacts(addressBookId?: string): Promise { if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]); return [...this.data.contacts]; } async getAllContacts(): Promise { return [...this.data.contacts]; } async getContact(contactId: string): Promise { return this.data.contacts.find(c => c.id === contactId) ?? null; } async createContact(contact: Partial): Promise { const full: ContactCard = { id: generateDemoId('contact'), addressBookIds: contact.addressBookIds ?? { 'demo-addressbook-personal': true }, ...contact, } as ContactCard; this.data.contacts.push(full); return full; } async updateContact(contactId: string, updates: Partial): Promise { const contact = this.data.contacts.find(c => c.id === contactId); if (contact) Object.assign(contact, updates); } async deleteContact(contactId: string): Promise { this.data.contacts = this.data.contacts.filter(c => c.id !== contactId); } async searchContacts(query: string): Promise { const q = query.toLowerCase(); return this.data.contacts.filter(c => { const nameStr = c.name?.components?.map(nc => nc.value).join(' ').toLowerCase() ?? ''; const emailStr = Object.values(c.emails ?? {}).map(e => e.address).join(' ').toLowerCase(); return nameStr.includes(q) || emailStr.includes(q); }); } // ── Calendars ───────────────────────────────────────────────── getCalendarsAccountId(): string { return 'demo-account'; } async getCalendars(): Promise { return [...this.data.calendars]; } async getAllCalendars(): Promise { return [...this.data.calendars]; } async createCalendar(calendar: Partial): Promise { const full: Calendar = { id: generateDemoId('calendar'), name: calendar.name ?? 'New Calendar', description: calendar.description ?? null, color: calendar.color ?? '#6366f1', sortOrder: calendar.sortOrder ?? 99, 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 }, ...calendar, } as Calendar; this.data.calendars.push(full); return full; } async updateCalendar(calendarId: string, updates: Partial): Promise { const cal = this.data.calendars.find(c => c.id === calendarId); if (cal) Object.assign(cal, updates); } async deleteCalendar(calendarId: string): Promise { this.data.calendars = this.data.calendars.filter(c => c.id !== calendarId); this.data.calendarEvents = this.data.calendarEvents.filter(e => !e.calendarIds[calendarId]); } async getCalendarEvents(calendarIds?: string[]): Promise { let events = [...this.data.calendarEvents]; if (calendarIds?.length) { events = events.filter(e => calendarIds.some(cid => e.calendarIds[cid])); } return events; } async getCalendarEvent(id: string): Promise { return this.data.calendarEvents.find(e => e.id === id) ?? null; } async createCalendarEvent(event: Partial): Promise { const full: CalendarEvent = { id: generateDemoId('event'), calendarIds: event.calendarIds ?? { 'demo-calendar-personal': true }, '@type': 'Event', uid: generateDemoId('uid'), title: event.title ?? 'New Event', description: event.description ?? '', descriptionContentType: 'text/plain', isDraft: false, isOrigin: true, created: new Date().toISOString(), updated: new Date().toISOString(), sequence: 0, start: event.start ?? new Date().toISOString(), duration: event.duration ?? 'PT1H', timeZone: event.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone, utcStart: event.utcStart ?? null, utcEnd: event.utcEnd ?? null, showWithoutTime: event.showWithoutTime ?? false, status: 'confirmed', freeBusyStatus: 'busy', privacy: 'public', 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, ...event, } as CalendarEvent; this.data.calendarEvents.push(full); return full; } async batchCreateCalendarEvents(events: Partial[]): Promise<{ created: CalendarEvent[]; failed: string[] }> { const created: CalendarEvent[] = []; for (const event of events) { const full = await this.createCalendarEvent(event); created.push(full); } return { created, failed: [] }; } 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() }); } async deleteCalendarEvent(eventId: string): Promise { this.data.calendarEvents = this.data.calendarEvents.filter(e => e.id !== eventId); } async batchDeleteCalendarEvents(eventIds: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { const idSet = new Set(eventIds); this.data.calendarEvents = this.data.calendarEvents.filter(e => !idSet.has(e.id)); return { destroyed: eventIds, notDestroyed: [] }; } async queryCalendarEvents(filter: CalendarEventFilter): Promise { return this.data.calendarEvents.filter(e => { if (filter.after && e.start < filter.after) return false; if (filter.before && e.start > filter.before) return false; return true; }); } async queryAllCalendarEvents(filter: CalendarEventFilter): Promise { return this.queryCalendarEvents(filter); } async parseCalendarEvents(): Promise[]> { return []; // no-op in demo } // ── Calendar Tasks ──────────────────────────────────────────── async getCalendarTasks(calendarIds?: string[]): Promise { let tasks = this.data.calendarTasks || []; if (calendarIds) { tasks = tasks.filter(t => Object.keys(t.calendarIds).some(id => calendarIds.includes(id))); } return [...tasks]; } async createCalendarTask(task: Partial): Promise { const full: CalendarTask = { id: generateDemoId('task'), uid: generateDemoId('task-uid'), '@type': 'Task', calendarIds: task.calendarIds || { [this.data.calendars[0]?.id || 'cal-1']: true }, title: task.title || '', description: task.description || '', due: task.due || null, start: task.start || null, duration: task.duration || null, timeZone: task.timeZone || null, showWithoutTime: task.showWithoutTime ?? true, progress: task.progress || 'needs-action', progressUpdated: null, priority: task.priority || 0, privacy: task.privacy || 'public', keywords: task.keywords || null, categories: task.categories || null, color: task.color || null, created: new Date().toISOString(), updated: new Date().toISOString(), recurrenceRules: task.recurrenceRules || null, alerts: task.alerts || null, relatedTo: task.relatedTo || null, }; this.data.calendarTasks.push(full); return full; } async updateCalendarTask(taskId: string, updates: Partial): Promise { const task = this.data.calendarTasks.find(t => t.id === taskId); if (task) Object.assign(task, updates, { updated: new Date().toISOString() }); } async deleteCalendarTask(taskId: string): Promise { this.data.calendarTasks = this.data.calendarTasks.filter(t => t.id !== taskId); } // ── Sieve / Filters ────────────────────────────────────────── getSieveAccountId(): string { return 'demo-account'; } getSieveCapabilities(): SieveCapabilities | null { return { ...this.data.sieveCapabilities }; } async getSieveScripts(): Promise { return [...this.data.sieveScripts]; } async getSieveScriptContent(blobId: string): Promise { return this.data.sieveContent[blobId] ?? ''; } async createSieveScript(name: string, content: string, activate?: boolean): Promise { const blobId = generateDemoId('sieve-blob'); const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false }; this.data.sieveScripts.push(script); this.data.sieveContent[blobId] = content; if (activate) { for (const s of this.data.sieveScripts) { if (s.id !== script.id) s.isActive = false; } } return script; } async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise { const script = this.data.sieveScripts.find(s => s.id === scriptId); if (!script) return; const blobId = generateDemoId('sieve-blob'); this.data.sieveContent[blobId] = content; script.blobId = blobId; if (activate !== undefined) { script.isActive = activate; if (activate) { for (const s of this.data.sieveScripts) { if (s.id !== scriptId) s.isActive = false; } } } } async deleteSieveScript(scriptId: string): Promise { this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId); } async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> { return { isValid: true }; } // ── Files (FileNode) ───────────────────────────────────────── getFilesAccountId(): string { return 'demo-account'; } async probeFileNodeSupport(): Promise { return true; } async listFileNodes(parentId: string | null): Promise { return this.data.fileNodes.filter(n => n.parentId === parentId); } async getFileNodes(ids: string[] | null): Promise { if (ids === null) return [...this.data.fileNodes]; return this.data.fileNodes.filter(n => ids.includes(n.id)); } async createFileDirectory(name: string, parentId: string | null): Promise { const node: FileNode = { id: generateDemoId('file'), parentId, name, type: 'd', blobId: null, size: 0, created: new Date().toISOString(), updated: new Date().toISOString(), }; this.data.fileNodes.push(node); return node; } async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise { const node: FileNode = { id: generateDemoId('file'), parentId, name, type, blobId, size, created: new Date().toISOString(), updated: new Date().toISOString(), }; this.data.fileNodes.push(node); return node; } async updateFileNode(id: string, updates: Partial>): Promise { const node = this.data.fileNodes.find(n => n.id === id); if (node) Object.assign(node, updates, { updated: new Date().toISOString() }); } async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> { const idSet = new Set(ids); this.data.fileNodes = this.data.fileNodes.filter(n => !idSet.has(n.id)); return { destroyed: ids, notDestroyed: [] }; } async copyFileNode(id: string, newName: string, parentId: string | null): Promise { const original = this.data.fileNodes.find(n => n.id === id); if (!original) throw new Error('File node not found'); return this.createFileNode(newName, original.blobId ?? '', original.type, original.size, parentId); } // ── S/MIME raw-email helpers ────────────────────────────────── async importRawEmail(): Promise { return generateDemoId('email'); } async submitEmail(): Promise { /* no-op */ } async sendRawEmail(): Promise { /* no-op */ } // ── Internal helpers ────────────────────────────────────────── private recalcMailboxCounts(): void { for (const mb of this.data.mailboxes) { const inMb = this.data.emails.filter(e => e.mailboxIds[mb.id]); mb.totalEmails = inMb.length; mb.unreadEmails = inMb.filter(e => !e.keywords.$seen).length; mb.totalThreads = new Set(inMb.map(e => e.threadId)).size; mb.unreadThreads = new Set(inMb.filter(e => !e.keywords.$seen).map(e => e.threadId)).size; } } private startIncomingEmailTimer(): void { this.stopIncomingEmailTimer(); const scheduleNext = () => { const delay = 60_000 + Math.random() * 60_000; // 60-120 seconds this.incomingTimer = setTimeout(() => { this.simulateIncomingEmail(); scheduleNext(); }, delay); }; scheduleNext(); } private stopIncomingEmailTimer(): void { if (this.incomingTimer) { clearTimeout(this.incomingTimer); this.incomingTimer = null; } } 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 subjects = [ 'Quick question about the project', 'Meeting rescheduled to tomorrow', 'FYI: Updated documentation', 'Can you review this PR?', 'Lunch today?', 'Important: deadline reminder', ]; const sender = senders[Math.floor(Math.random() * senders.length)]; const subject = subjects[Math.floor(Math.random() * subjects.length)]; const id = generateDemoId('email'); const email: Email = { id, threadId: generateDemoId('thread'), mailboxIds: { 'demo-mailbox-inbox': true }, keywords: {}, size: 1800, receivedAt: new Date().toISOString(), from: [sender], to: [{ name: 'Demo User', email: 'demo@example.com' }], subject, sentAt: new Date().toISOString(), preview: `Hi, ${subject.toLowerCase()}. Let me know what you think.`, hasAttachment: false, textBody: [{ partId: '1', blobId: generateDemoId('blob'), size: 120, type: 'text/plain' }], bodyValues: { '1': { value: `Hi,\n\n${subject}. Let me know what you think.\n\nBest,\n${sender.name}` }, }, messageId: `<${id}@demo.example.com>`, }; this.data.emails.unshift(email); this.recalcMailboxCounts(); // Notify state change to trigger UI refresh this.stateChangeCallback?.({ '@type': 'StateChange', changed: { 'demo-account': { Email: generateDemoId('state'), Mailbox: generateDemoId('state') } }, }); } }