Phase 1 step 6 of VNCprodbuild, resolving the step-5 DECISION gate (human
confirmed: WebSocket push, not polling, not quit-to-tray).
lib/jmap/client.ts: getWebSocketUrl() discovers the push endpoint from the
session's own urn:ietf:params:jmap:websocket capability (mirrors
getEventSourceUrl()'s existing pattern) - not hardcoded to any one server,
rewritten to the client's own host the same way apiUrl/downloadUrl/
eventSourceUrl already are (rewriteWebSocketUrl(), scheme-aware since ws/wss
can never share an origin string with the client's http/https serverUrl).
setupPushNotifications() now tries WS first when advertised, falling back to
the existing SSE/polling chain when not. connectWebSocket() subscribes via
WebSocketPushEnable and routes incoming StateChange frames through the exact
same stateChangeCallback that SSE/polling already feed - so
stores/email-store.ts's handleStateChange (mailbox/email refresh, scheduled
mail, calendar, filters) and handleNewEmailNotification (the new-mail toast/
sound signal) all work unchanged regardless of which transport delivered the
change.
Reconnect/backoff: exponential with full jitter (1s base, 30s cap - unlike
SSE's fixed 3s retry, explicitly requested since a long-lived WebSocket can
be dropped by sleep/network-switch/idle-proxy repeatedly in a row). An
app-level heartbeat (Core/echo every 30s, force-reconnect after 90s of
silence) catches connections that report readyState OPEN long after the
underlying path is actually gone, mirroring the existing SSE ping monitor.
Circuit breaker (wsConsecutiveFailures/wsPermanentlyDisabled): gives up on
WS after 5 CONSECUTIVE handshake failures (never reaching "open" - a
connection that opened fine and dropped later doesn't count) and falls back
to SSE/polling for the rest of the client instance's life. This is not
theoretical - verified empirically against the actual sandbox server this
was built against:
curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: ..." \
-H "Sec-WebSocket-Protocol: jmap" https://stalwart.sandbox.vnc.de/jmap/ws
-> 401 Unauthorized, WWW-Authenticate: Bearer/Basic
Stalwart's /jmap/ws requires the same HTTP Authorization header as every
other JMAP endpoint on the upgrade request itself, and the browser
WebSocket constructor cannot attach custom headers to that handshake (a
WHATWG spec restriction - credentials-in-URL is also explicitly rejected).
Every connection attempt from this renderer-side client will therefore fail
against Stalwart specifically and fall back to SSE (which keeps working
exactly as before - zero regression). Implemented for real anyway, not
stubbed: it's fully spec-correct and activates automatically against any
server whose WS endpoint doesn't share this auth model (e.g. behind a
cookie-authenticating proxy), and the alternative (opening it from
Electron's main process via a header-capable client, which would need raw
credentials piped over IPC from the renderer) is a materially bigger
security-sensitive change than what was scoped here. Documented in detail
in the code comments above the new fields.
lib/jmap/client-interface.ts + lib/demo/demo-client.ts: getWebSocketUrl()
added to the interface (demo client returns null, matching
getEventSourceUrl's existing stub).
app/(main)/[locale]/page.tsx: the existing "new mail arrived" effect (which
already plays a sound, transport-agnostically, whenever
stores/email-store.ts sets newEmailNotification for a genuine new top-of-
inbox message) now also calls lib/electron-bridge.ts's
showElectronNotification() when isElectronShell() - firing the native
notification bridge built in the step-3 commit, gated on the same
emailNotificationsEnabled setting the sound already uses. Fallback title/
body text ("New mail" / "(no subject)") matches public/sw.js's existing
push-notification fallback strings rather than introducing new i18n keys
for a rarely-hit edge case.
Verified: full lib/__tests__ JMAP suite green (158/158 across 13 files,
excluding one pre-existing unrelated flaky test - jmap-client-resilience's
ping-failure-reconnect-ordering assertion uses real timers and fails
~75% of the time on both this branch's base commit and this change,
confirmed by running the untouched baseline the same way). npm run
test:electron still green (4/4) after a full rebuild.
1264 lines
52 KiB
TypeScript
1264 lines
52 KiB
TypeScript
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, ScheduledEmail, SendEmailResult, SharedAccount } 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<string, Blob>();
|
|
private scheduledSubmissions = new Map<string, { id: string; emailId: string; identityId: string; sendAt: string; undoStatus: 'pending' | 'final' | 'canceled'; isSmime: boolean }>();
|
|
private connectionCallback: ((connected: boolean) => void) | null = null;
|
|
private stateChangeCallback: ((change: StateChange) => void) | null = null;
|
|
private lastStates: AccountStates = {};
|
|
private incomingTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
constructor() {
|
|
this.data = getDemoData();
|
|
}
|
|
|
|
// ── Connection lifecycle ──────────────────────────────────────
|
|
|
|
async connect(): Promise<void> {
|
|
// Start simulated incoming email timer
|
|
this.startIncomingEmailTimer();
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.stopIncomingEmailTimer();
|
|
this.connectionCallback = null;
|
|
this.stateChangeCallback = null;
|
|
}
|
|
|
|
async reconnect(): Promise<void> { /* no-op */ }
|
|
async ping(): Promise<void> { /* 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 capability === 'urn:ietf:params:jmap:submission';
|
|
}
|
|
|
|
getCapabilities(): Record<string, unknown> {
|
|
return {
|
|
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
|
|
'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': {},
|
|
};
|
|
}
|
|
|
|
getMaxSizeUpload(): number { return 50_000_000; }
|
|
getMaxCallsInRequest(): number { return 16; }
|
|
getMaxObjectsInGet(): number { return 500; }
|
|
getMaxObjectsInSet(): number { return 500; }
|
|
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
|
|
hasDelayedSend(): boolean { return true; }
|
|
getEventSourceUrl(): string | null { return null; }
|
|
getWebSocketUrl(): 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<never[]> { return []; }
|
|
async setCalendarShare(): Promise<void> { /* demo: no-op */ }
|
|
async setAddressBookShare(): Promise<void> { /* 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 }; }
|
|
|
|
// PushSubscription endpoints have no meaning in demo mode - the demo client
|
|
// never makes real network calls so there's nothing for the relay to push to.
|
|
async listPushSubscriptions() { return []; }
|
|
async createPushSubscription(): Promise<string> {
|
|
throw new Error('Push subscriptions are not available in demo mode');
|
|
}
|
|
async verifyPushSubscription(): Promise<void> {
|
|
throw new Error('Push subscriptions are not available in demo mode');
|
|
}
|
|
async updatePushSubscription(): Promise<boolean> { return false; }
|
|
async destroyPushSubscription(): Promise<void> { /* no-op */ }
|
|
|
|
// ── Quota ─────────────────────────────────────────────────────
|
|
|
|
async getQuota(): Promise<{ used: number; total: number } | null> {
|
|
return { used: 245_366_784, total: 1_073_741_824 };
|
|
}
|
|
|
|
// ── Mailboxes ─────────────────────────────────────────────────
|
|
|
|
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
|
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
|
|
|
|
async createMailbox(name: string, parentId?: string, _accountId?: string): Promise<Mailbox> {
|
|
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<void> {
|
|
const mb = this.data.mailboxes.find(m => m.id === mailboxId);
|
|
if (mb) Object.assign(mb, changes);
|
|
}
|
|
|
|
async deleteMailbox(mailboxId: string): Promise<void> {
|
|
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 ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Minimal JMAP filter evaluator for demo mode: supports the conditions the
|
|
* message-list category tabs use (hasKeyword / notKeyword / from and
|
|
* AND / OR / NOT operators). Unknown conditions match nothing.
|
|
*/
|
|
private matchesFilter(e: Email, filter: Record<string, unknown>): boolean {
|
|
if (typeof filter.operator === 'string') {
|
|
const conditions = (Array.isArray(filter.conditions) ? filter.conditions : []) as Record<string, unknown>[];
|
|
switch (filter.operator) {
|
|
case 'AND': return conditions.every(c => this.matchesFilter(e, c));
|
|
case 'OR': return conditions.some(c => this.matchesFilter(e, c));
|
|
case 'NOT': return !conditions.some(c => this.matchesFilter(e, c));
|
|
default: return false;
|
|
}
|
|
}
|
|
if (typeof filter.hasKeyword === 'string' && !e.keywords[filter.hasKeyword]) return false;
|
|
if (typeof filter.notKeyword === 'string' && e.keywords[filter.notKeyword]) return false;
|
|
if (typeof filter.from === 'string') {
|
|
const q = filter.from.toLowerCase();
|
|
const match = (e.from || []).some(f =>
|
|
(f.email || '').toLowerCase().includes(q) || (f.name || '').toLowerCase().includes(q));
|
|
if (!match) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
|
let filtered = this.data.emails;
|
|
if (mailboxId) {
|
|
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
|
}
|
|
if (hasKeyword) {
|
|
filtered = filtered.filter(e => e.keywords[hasKeyword]);
|
|
}
|
|
if (extraFilter) {
|
|
filtered = filtered.filter(e => this.matchesFilter(e, extraFilter));
|
|
}
|
|
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
|
|
filtered.sort((a, b) =>
|
|
pinRank(b) - pinRank(a) ||
|
|
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 getSomeEmails(emailsId: string[], _accountId?: string): Promise<Email[]> {
|
|
if (!emailsId || emailsId.length === 0) {
|
|
return [];
|
|
}
|
|
const filtered = this.data.emails.filter(e => emailsId.includes(e.id));
|
|
|
|
filtered.sort((a, b) =>
|
|
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
|
);
|
|
|
|
return filtered;
|
|
}
|
|
|
|
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
|
|
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
|
|
}
|
|
|
|
async getEmail(emailId: string): Promise<Email | null> {
|
|
return this.data.emails.find(e => e.id === emailId) ?? null;
|
|
}
|
|
|
|
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
|
|
const result: Record<string, { total: number; unread: number }> = {};
|
|
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 getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, _accountId?: string): Promise<Record<string, number>> {
|
|
const inBox = this.data.emails.filter(e => e.mailboxIds[mailboxId] && !e.keywords.$seen);
|
|
const result: Record<string, number> = {};
|
|
for (const tab of tabs) {
|
|
result[tab.id] = tab.filter
|
|
? inBox.filter(e => this.matchesFilter(e, tab.filter as Record<string, unknown>)).length
|
|
: inBox.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<string, unknown>, _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 };
|
|
}
|
|
|
|
async searchSentRecipients(query: string, _sentMailboxId: string, _accountId?: string, _limit: number = 60): Promise<Array<{ name: string; email: string }>> {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return [];
|
|
const byEmail = new Map<string, { name: string; email: string }>();
|
|
for (const email of this.data.emails) {
|
|
for (const r of [...(email.to || []), ...(email.cc || [])]) {
|
|
if (!r.email) continue;
|
|
const key = r.email.toLowerCase();
|
|
if (byEmail.has(key)) continue;
|
|
if (key.includes(q) || (r.name && r.name.toLowerCase().includes(q))) {
|
|
byEmail.set(key, { name: r.name || '', email: r.email });
|
|
}
|
|
}
|
|
}
|
|
return Array.from(byEmail.values());
|
|
}
|
|
|
|
// ── Email mutations ───────────────────────────────────────────
|
|
|
|
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
|
|
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, _accountId?: string): Promise<void> {
|
|
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, _accountId?: string): Promise<void> {
|
|
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<string, boolean>): Promise<void> {
|
|
const email = this.data.emails.find(e => e.id === emailId);
|
|
if (email) email.keywords = { ...email.keywords, ...keywords };
|
|
}
|
|
|
|
async setKeyword(emailId: string, keyword: string): Promise<void> {
|
|
const email = this.data.emails.find(e => e.id === emailId);
|
|
if (email) email.keywords[keyword] = true;
|
|
}
|
|
|
|
async removeKeyword(emailId: string, keyword: string): Promise<void> {
|
|
const email = this.data.emails.find(e => e.id === emailId);
|
|
if (email) delete email.keywords[keyword];
|
|
}
|
|
|
|
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>): Promise<void> {
|
|
for (const id of emailIds) {
|
|
const email = this.data.emails.find(e => e.id === id);
|
|
if (!email) continue;
|
|
for (const [pointer, value] of Object.entries(patch)) {
|
|
const keyword = pointer.startsWith('keywords/') ? pointer.slice('keywords/'.length) : pointer;
|
|
if (value === null || value === false) delete email.keywords[keyword];
|
|
else email.keywords[keyword] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
|
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<void> {
|
|
this.data.emails = this.data.emails.filter(e => e.id !== emailId);
|
|
this.recalcMailboxCounts();
|
|
}
|
|
|
|
async moveToTrash(emailId: string, trashMailboxId: string, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
|
const email = this.data.emails.find(e => e.id === emailId);
|
|
if (!email) return;
|
|
email.mailboxIds = { [trashMailboxId]: true };
|
|
if (markAsRead) email.keywords.$seen = true;
|
|
this.recalcMailboxCounts();
|
|
}
|
|
|
|
async batchDeleteEmails(emailIds: string[], _accountId?: string): Promise<void> {
|
|
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, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
|
for (const id of emailIds) {
|
|
const email = this.data.emails.find(e => e.id === id);
|
|
if (email) {
|
|
email.mailboxIds = { [toMailboxId]: true };
|
|
if (markAsRead) email.keywords.$seen = true;
|
|
}
|
|
}
|
|
this.recalcMailboxCounts();
|
|
}
|
|
|
|
async batchArchiveEmails(
|
|
emails: Array<{ id: string; receivedAt: string }>,
|
|
archiveMailboxId: string,
|
|
mode: 'single' | 'year' | 'month',
|
|
_existingMailboxes: Mailbox[],
|
|
_accountId?: string,
|
|
): Promise<void> {
|
|
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<void> {
|
|
const email = this.data.emails.find(e => e.id === emailId);
|
|
if (email) email.mailboxIds = { [toMailboxId]: true };
|
|
this.recalcMailboxCounts();
|
|
}
|
|
|
|
async emptyMailbox(mailboxId: string, _accountId?: string): Promise<number> {
|
|
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<number> {
|
|
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<number> {
|
|
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, _accountId?: string, markAsRead?: boolean): Promise<void> {
|
|
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 };
|
|
if (markAsRead) email.keywords.$seen = true;
|
|
}
|
|
this.recalcMailboxCounts();
|
|
}
|
|
|
|
async undoSpam(emailId: string, originalMailboxId: string): Promise<void> {
|
|
const email = this.data.emails.find(e => e.id === emailId);
|
|
if (email) email.mailboxIds = { [originalMailboxId]: true };
|
|
this.recalcMailboxCounts();
|
|
}
|
|
|
|
// ── Threads ───────────────────────────────────────────────────
|
|
|
|
async getThread(threadId: string): Promise<Thread | null> {
|
|
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 getThreads(threadIds: string[]): Promise<Thread[]> {
|
|
return threadIds
|
|
.map(tid => {
|
|
const emails = this.data.emails.filter(e => e.threadId === tid);
|
|
if (emails.length === 0) return null;
|
|
return { id: tid, emailIds: emails.map(e => e.id) };
|
|
})
|
|
.filter((t): t is Thread => t !== null);
|
|
}
|
|
|
|
async getThreadEmails(threadId: string): Promise<Email[]> {
|
|
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,
|
|
htmlBody?: string,
|
|
): Promise<string> {
|
|
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?.length ? cc.map(e => ({ email: e })) : undefined,
|
|
bcc: bcc?.length ? bcc.map(e => ({ email: e })) : undefined,
|
|
subject,
|
|
sentAt: new Date().toISOString(),
|
|
preview: body.substring(0, 200),
|
|
hasAttachment: !!attachments?.length,
|
|
textBody: [{ partId: htmlBody ? 'text' : '1', blobId: generateDemoId('blob'), size: body.length, type: 'text/plain' }],
|
|
htmlBody: htmlBody
|
|
? [{ partId: 'html', blobId: generateDemoId('blob'), size: htmlBody.length, type: 'text/html' }]
|
|
: [],
|
|
bodyValues: htmlBody
|
|
? { text: { value: body }, html: { value: htmlBody } }
|
|
: { '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 }>,
|
|
inReplyTo?: string[],
|
|
references?: string[],
|
|
delayedUntil?: string,
|
|
_envelopeMailFrom?: string,
|
|
): Promise<SendEmailResult> {
|
|
// 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: { [delayedUntil ? (this.data.mailboxes.find(m => m.role === 'drafts')?.id || 'demo-mailbox-drafts') : (sentMb?.id || 'demo-mailbox-sent')]: 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' }],
|
|
to: to.map(e => ({ email: e })),
|
|
cc: cc?.length ? cc.map(e => ({ email: e })) : undefined,
|
|
bcc: bcc?.length ? bcc.map(e => ({ email: e })) : undefined,
|
|
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>`,
|
|
inReplyTo: inReplyTo?.length ? inReplyTo : undefined,
|
|
references: references?.length ? references : undefined,
|
|
};
|
|
this.data.emails.push(email);
|
|
let emailSubmissionId: string | undefined;
|
|
if (delayedUntil) {
|
|
emailSubmissionId = generateDemoId('submission');
|
|
this.scheduledSubmissions.set(emailSubmissionId, {
|
|
id: emailSubmissionId,
|
|
emailId: email.id,
|
|
identityId: _identityId || 'demo-identity',
|
|
sendAt: delayedUntil,
|
|
undoStatus: 'pending',
|
|
isSmime: false,
|
|
});
|
|
}
|
|
this.recalcMailboxCounts();
|
|
return delayedUntil
|
|
? { scheduled: true, emailId: email.id, emailSubmissionId, sendAt: delayedUntil }
|
|
: { scheduled: false, emailId: email.id };
|
|
}
|
|
|
|
async sendImipReply(): Promise<void> { /* no-op in demo */ }
|
|
async sendImipInvitation(): Promise<void> { /* no-op in demo */ }
|
|
async sendImipCancellation(): Promise<void> { /* no-op in demo */ }
|
|
|
|
// ── Blobs ─────────────────────────────────────────────────────
|
|
|
|
async uploadBlob(
|
|
file: File,
|
|
opts?: { onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal },
|
|
): Promise<{ blobId: string; size: number; type: string }> {
|
|
if (opts?.signal?.aborted) {
|
|
throw new DOMException('Upload aborted', 'AbortError');
|
|
}
|
|
opts?.onProgress?.(0, file.size);
|
|
const blobId = generateDemoId('blob');
|
|
this.blobStore.set(blobId, file);
|
|
opts?.onProgress?.(file.size, file.size);
|
|
return { blobId, size: file.size, type: file.type };
|
|
}
|
|
|
|
async importEmail(): Promise<string | null> {
|
|
return generateDemoId('email');
|
|
}
|
|
|
|
async sendReadReceipt(): Promise<void> {
|
|
// Demo mode: no real network send.
|
|
}
|
|
|
|
getBlobDownloadUrl(blobId: string): string {
|
|
return `data:application/octet-stream;demo-blob=${blobId}`;
|
|
}
|
|
|
|
async fetchBlob(blobId: string): Promise<Blob> {
|
|
return this.blobStore.get(blobId) ?? new Blob(['[Demo placeholder content]'], { type: 'text/plain' });
|
|
}
|
|
|
|
async fetchBlobAsObjectUrl(blobId: string): Promise<string> {
|
|
const blob = await this.fetchBlob(blobId);
|
|
return URL.createObjectURL(blob);
|
|
}
|
|
|
|
async fetchBlobArrayBuffer(blobId: string): Promise<ArrayBuffer> {
|
|
const blob = await this.fetchBlob(blobId);
|
|
return blob.arrayBuffer();
|
|
}
|
|
|
|
async downloadBlob(blobId: string, name?: string): Promise<void> {
|
|
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<Identity[]> { return [...this.data.identities]; }
|
|
|
|
async createIdentity(
|
|
name: string, email: string,
|
|
replyTo?: EmailAddress[] | null, bcc?: EmailAddress[] | null,
|
|
textSignature?: string | null, htmlSignature?: string | null,
|
|
): Promise<Identity> {
|
|
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 | null; replyTo?: EmailAddress[] | null; bcc?: EmailAddress[] | null; textSignature?: string | null; htmlSignature?: string | null }): Promise<void> {
|
|
const identity = this.data.identities.find(i => i.id === identityId);
|
|
if (!identity) return;
|
|
if (updates.name !== undefined) identity.name = updates.name ?? '';
|
|
if (updates.replyTo !== undefined) identity.replyTo = updates.replyTo ?? undefined;
|
|
if (updates.bcc !== undefined) identity.bcc = updates.bcc ?? undefined;
|
|
if (updates.textSignature !== undefined) identity.textSignature = updates.textSignature ?? '';
|
|
if (updates.htmlSignature !== undefined) identity.htmlSignature = updates.htmlSignature ?? '';
|
|
}
|
|
|
|
async deleteIdentity(identityId: string): Promise<void> {
|
|
this.data.identities = this.data.identities.filter(i => i.id !== identityId);
|
|
}
|
|
|
|
// ── Vacation ──────────────────────────────────────────────────
|
|
|
|
async getVacationResponse(_accountId?: string): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
|
|
|
|
async setVacationResponse(updates: Partial<VacationResponse>, _accountId?: string): Promise<void> {
|
|
Object.assign(this.data.vacationResponse, updates);
|
|
}
|
|
|
|
// ── Contacts ──────────────────────────────────────────────────
|
|
|
|
getContactsAccountId(): string { return 'demo-account'; }
|
|
|
|
async getAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
|
async getAllAddressBooks(): Promise<AddressBook[]> { return [...this.data.addressBooks]; }
|
|
|
|
async createAddressBook(name: string): Promise<AddressBook> {
|
|
const book: AddressBook = { id: `demo-book-${Date.now()}`, name };
|
|
this.data.addressBooks.push(book);
|
|
return book;
|
|
}
|
|
|
|
async updateAddressBook(addressBookId: string, updates: Partial<AddressBook>): Promise<void> {
|
|
const book = this.data.addressBooks.find(b => b.id === addressBookId);
|
|
if (book) Object.assign(book, updates);
|
|
}
|
|
|
|
async deleteAddressBook(addressBookId: string): Promise<void> {
|
|
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<ContactCard[]> {
|
|
if (addressBookId) return this.data.contacts.filter(c => c.addressBookIds[addressBookId]);
|
|
return [...this.data.contacts];
|
|
}
|
|
|
|
async getAllContacts(): Promise<ContactCard[]> { return [...this.data.contacts]; }
|
|
|
|
async getContact(contactId: string): Promise<ContactCard | null> {
|
|
return this.data.contacts.find(c => c.id === contactId) ?? null;
|
|
}
|
|
|
|
async createContact(contact: Partial<ContactCard>): Promise<ContactCard> {
|
|
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<ContactCard>): Promise<void> {
|
|
const contact = this.data.contacts.find(c => c.id === contactId);
|
|
if (contact) Object.assign(contact, updates);
|
|
}
|
|
|
|
async deleteContact(contactId: string): Promise<void> {
|
|
this.data.contacts = this.data.contacts.filter(c => c.id !== contactId);
|
|
}
|
|
|
|
async searchContacts(query: string): Promise<ContactCard[]> {
|
|
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<Calendar[]> { return [...this.data.calendars]; }
|
|
async getAllCalendars(): Promise<Calendar[]> { return [...this.data.calendars]; }
|
|
|
|
async createCalendar(calendar: Partial<Calendar>): Promise<Calendar> {
|
|
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<Calendar>): Promise<void> {
|
|
const cal = this.data.calendars.find(c => c.id === calendarId);
|
|
if (cal) Object.assign(cal, updates);
|
|
}
|
|
|
|
async setDefaultCalendar(calendarId: string): Promise<void> {
|
|
for (const cal of this.data.calendars) {
|
|
cal.isDefault = cal.id === calendarId;
|
|
}
|
|
}
|
|
|
|
async deleteCalendar(calendarId: string): Promise<void> {
|
|
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<CalendarEvent[]> {
|
|
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<CalendarEvent | null> {
|
|
return this.data.calendarEvents.find(e => e.id === id) ?? null;
|
|
}
|
|
|
|
async createCalendarEvent(event: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
|
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<CalendarEvent>[]): 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<CalendarEvent>): Promise<void> {
|
|
const event = this.data.calendarEvents.find(e => e.id === eventId);
|
|
if (!event) throw new Error('Event not found');
|
|
Object.assign(event, updates, { updated: new Date().toISOString() });
|
|
}
|
|
|
|
async deleteCalendarEvent(eventId: string): Promise<void> {
|
|
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<CalendarEvent[]> {
|
|
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<CalendarEvent[]> {
|
|
return this.queryCalendarEvents(filter);
|
|
}
|
|
|
|
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
|
|
return []; // no-op in demo
|
|
}
|
|
|
|
// ── Calendar Tasks ────────────────────────────────────────────
|
|
|
|
async getCalendarTasks(calendarIds?: string[]): Promise<CalendarTask[]> {
|
|
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<CalendarTask>): Promise<CalendarTask> {
|
|
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<CalendarTask>): Promise<void> {
|
|
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<void> {
|
|
this.data.calendarTasks = this.data.calendarTasks.filter(t => t.id !== taskId);
|
|
}
|
|
|
|
// ── Sieve / Filters ──────────────────────────────────────────
|
|
|
|
getSharedAccounts(): SharedAccount[] {
|
|
return [{
|
|
id: 'demo-account',
|
|
name: 'Demo',
|
|
isPrimary: true,
|
|
capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true },
|
|
}];
|
|
}
|
|
|
|
getSieveAccountId(): string { return 'demo-account'; }
|
|
|
|
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
|
|
return this.getSharedAccounts()
|
|
.filter((a) => a.isPrimary || a.capabilities.sieve)
|
|
.map(({ id, name, isPrimary }) => ({ id, name, isPrimary }));
|
|
}
|
|
|
|
getSieveCapabilities(_accountId?: string): SieveCapabilities | null {
|
|
return { ...this.data.sieveCapabilities };
|
|
}
|
|
|
|
async getSieveScripts(_accountId?: string): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
|
|
|
|
async getSieveScriptContent(blobId: string, _accountId?: string): Promise<string> {
|
|
return this.data.sieveContent[blobId] ?? '';
|
|
}
|
|
|
|
async createSieveScript(name: string, content: string, activate?: boolean, _accountId?: string): Promise<SieveScript> {
|
|
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, _accountId?: string): Promise<void> {
|
|
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, _accountId?: string): Promise<void> {
|
|
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
|
|
}
|
|
|
|
async validateSieveScript(_content?: string, _accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
|
|
return { isValid: true };
|
|
}
|
|
|
|
// ── Files (FileNode) ─────────────────────────────────────────
|
|
|
|
getFilesAccountId(): string { return 'demo-account'; }
|
|
|
|
async probeFileNodeSupport(): Promise<boolean> { return true; }
|
|
|
|
async listFileNodes(parentId: string | null): Promise<FileNode[]> {
|
|
return this.data.fileNodes.filter(n => n.parentId === parentId);
|
|
}
|
|
|
|
async listAllFileNodes(): Promise<FileNode[]> {
|
|
return [...this.data.fileNodes];
|
|
}
|
|
|
|
async listAllFileNodesAcrossAccounts(): Promise<FileNode[]> {
|
|
return [...this.data.fileNodes];
|
|
}
|
|
|
|
async setFileNodeShare(): Promise<void> { /* demo: no-op */ }
|
|
|
|
async getFileNodes(ids: string[] | null): Promise<FileNode[]> {
|
|
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<FileNode> {
|
|
const node: FileNode = {
|
|
id: generateDemoId('file'),
|
|
parentId, name, type: 'd', blobId: null, size: 0,
|
|
created: new Date().toISOString(), modified: new Date().toISOString(),
|
|
};
|
|
this.data.fileNodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
|
const node: FileNode = {
|
|
id: generateDemoId('file'),
|
|
parentId, name, type, blobId, size,
|
|
created: new Date().toISOString(), modified: new Date().toISOString(),
|
|
};
|
|
this.data.fileNodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
|
const node = this.data.fileNodes.find(n => n.id === id);
|
|
if (node) Object.assign(node, updates, { modified: new Date().toISOString() });
|
|
}
|
|
|
|
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
|
|
const updated: string[] = [];
|
|
for (const [id, patch] of Object.entries(updates)) {
|
|
const node = this.data.fileNodes.find(n => n.id === id);
|
|
if (node) {
|
|
Object.assign(node, patch, { modified: new Date().toISOString() });
|
|
updated.push(id);
|
|
}
|
|
}
|
|
return { updated, notUpdated: {} };
|
|
}
|
|
|
|
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<FileNode> {
|
|
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<string> { return generateDemoId('email'); }
|
|
async copyEmailAcrossAccounts(): Promise<string> { return generateDemoId('email'); }
|
|
async submitEmail(): Promise<void> { /* no-op */ }
|
|
async submitRawEmail(blob: Blob,
|
|
identityId: string,
|
|
delayedUntil?: string,
|
|
_envelopeRecipients?: string[],): Promise<SendEmailResult> {
|
|
const emailId = generateDemoId('email');
|
|
let emailSubmissionId: string | undefined;
|
|
if (delayedUntil) {
|
|
emailSubmissionId = generateDemoId('submission');
|
|
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
|
|
}
|
|
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
|
|
}
|
|
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
|
|
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 },
|
|
keywords: delayedUntil ? { $seen: true, $draft: true } : { $seen: true },
|
|
size: 1024,
|
|
receivedAt: new Date().toISOString(),
|
|
from: [{ name: 'Demo User', email: 'demo@example.com' }],
|
|
to: [],
|
|
subject: 'S/MIME message',
|
|
preview: 'Signed/encrypted demo message',
|
|
hasAttachment: false,
|
|
};
|
|
this.data.emails.push(email);
|
|
let emailSubmissionId: string | undefined;
|
|
if (delayedUntil) {
|
|
emailSubmissionId = generateDemoId('submission');
|
|
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
|
|
}
|
|
this.recalcMailboxCounts();
|
|
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
|
|
}
|
|
|
|
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
|
|
const pending = Array.from(this.scheduledSubmissions.values())
|
|
.filter(s => s.undoStatus === 'pending')
|
|
.sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime());
|
|
const page = pending.slice(position, position + limit);
|
|
const emails = page.map((submission) => {
|
|
const email = this.data.emails.find(e => e.id === submission.emailId);
|
|
if (!email) return null;
|
|
return {
|
|
...email,
|
|
scheduledSendAt: submission.sendAt,
|
|
emailSubmissionId: submission.id,
|
|
scheduledIdentityId: submission.identityId,
|
|
scheduledUndoStatus: submission.undoStatus,
|
|
isScheduled: true,
|
|
isSmimeScheduled: submission.isSmime,
|
|
} satisfies ScheduledEmail;
|
|
}).filter((email): email is ScheduledEmail => email !== null);
|
|
const nextPosition = position + page.length;
|
|
return { emails, hasMore: nextPosition < pending.length, total: pending.length, nextPosition };
|
|
}
|
|
|
|
async cancelEmailSubmission(submissionId: string): Promise<void> {
|
|
const submission = this.scheduledSubmissions.get(submissionId);
|
|
if (submission) submission.undoStatus = 'canceled';
|
|
}
|
|
|
|
async rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult> {
|
|
await this.cancelEmailSubmission(submissionId);
|
|
const replacement = generateDemoId('submission');
|
|
this.scheduledSubmissions.set(replacement, { id: replacement, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: false });
|
|
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
|
|
}
|
|
|
|
// Mirrors JMAPClient.restoreEmailToDraft: the third parameter is ignored and
|
|
// the message ends up in Drafts only (full mailboxIds replacement).
|
|
async restoreEmailToDraft(emailId: string, draftMailboxId: string, _sentMailboxId?: string): Promise<void> {
|
|
const email = this.data.emails.find(e => e.id === emailId);
|
|
if (!email) return;
|
|
email.mailboxIds = { [draftMailboxId]: true };
|
|
email.keywords.$draft = true;
|
|
this.recalcMailboxCounts();
|
|
}
|
|
|
|
// ── 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') } },
|
|
});
|
|
}
|
|
}
|