Compose recipient fields only suggested existing contacts and directory users, so people you had emailed before but never saved as a contact never came up. This adds an Outlook-Web-style suggestion flow. On startup the Sent folder is read once (metadata only) to build a cache of addresses you have written to; those are merged into the autocomplete after contacts and directory principals, deduped, contacts winning. When the recipient is not in the cache, the dropdown offers a "search the server" row that queries the Sent folder on demand. That lookup fetches only the to/cc fields (no subject, body or attachments) and returns the matching addresses, deduped. New strings are added to all 20 locales.
1171 lines
48 KiB
TypeScript
1171 lines
48 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 },
|
|
'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; }
|
|
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 ────────────────────────────────────────────────────
|
|
|
|
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
|
let filtered = this.data.emails;
|
|
if (mailboxId) {
|
|
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
|
}
|
|
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
|
|
const total = filtered.length;
|
|
const emails = filtered.slice(position, position + limit);
|
|
return { emails, hasMore: position + limit < total, total };
|
|
}
|
|
|
|
async getEmailsInMailbox(mailboxId: string): Promise<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 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 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(), updated: new Date().toISOString(),
|
|
};
|
|
this.data.fileNodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise<FileNode> {
|
|
const node: FileNode = {
|
|
id: generateDemoId('file'),
|
|
parentId, name, type, blobId, size,
|
|
created: new Date().toISOString(), updated: new Date().toISOString(),
|
|
};
|
|
this.data.fileNodes.push(node);
|
|
return node;
|
|
}
|
|
|
|
async updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void> {
|
|
const node = this.data.fileNodes.find(n => n.id === id);
|
|
if (node) Object.assign(node, updates, { updated: 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, { updated: 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 submitEmail(): Promise<void> { /* no-op */ }
|
|
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 };
|
|
}
|
|
|
|
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;
|
|
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
|
|
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') } },
|
|
});
|
|
}
|
|
}
|