If you have a large number of tags, getTagCounts would not be able to get the unread counts because it did not respect maxCallsInRequest, even though the value was actually read out, it was just ignored. There are also other places where the limits were not respected. Batching is now generalized in a helper that also takes maxObjectsInSet, which also was ignored, into account and is applied to all functions. In addition, the dev mock now also advertises and enforces the limits, so these issues can get picked up during development. Possible closes #699 Possibly closes #399
1263 lines
52 KiB
TypeScript
1263 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; }
|
|
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') } },
|
|
});
|
|
}
|
|
}
|