Files
SRCmail/lib/demo/demo-client.ts
T
KazNIISA ITandLinus Rath 4dc76bbb47 fix(jmap): file post-send message with a full mailboxIds replacement
Sending mail through Bulwark could leave the delivered message stuck in Drafts
(keeping the $draft keyword) and never file a copy into Sent, with no error
shown, for accounts whose Drafts/Sent mailbox JMAP id is a purely-numeric
string (e.g. "0").

The post-send Drafts->Sent move is expressed as onSuccessUpdateEmail on
EmailSubmission/set using `mailboxIds/<id>` JSON-Pointer patches. Stalwart
up to 0.16.4 (observed on 0.15.5) rejects an Email/set PatchObject whose
pointer token is all digits -- e.g. `mailboxIds/0` -- with invalidProperties
"Invalid patch value", treating the token as a JSON-Pointer array index even
though mailboxIds is a JSON object (cf. RFC 6901 section 4; RFC 8620 section
1.2 warns servers against such interop-hostile ids). Because the move runs
only AFTER the EmailSubmission already succeeded, the message is delivered but
the filing update is silently rejected: the send code inspects only
`notCreated`, not the onSuccessUpdateEmail `notUpdated` result, so nothing
surfaces to the user.

Stalwart fixed the pointer parsing server-side in 0.16.5
(stalwartlabs/stalwart@175f34ea, jmap-tools 0.1.4 -> 0.1.5; a sibling symptom
was stalwartlabs/stalwart#2985). The client-side change is still worthwhile:
earlier Stalwart deployments remain in the wild, and a full-property
replacement both states the actual intent of the move and emits no per-id
pointer token that another server could mishandle.

Replace the per-id pointer patches at every post-send / undo-send move site
(send, scheduled send, raw-import send, reschedule, and restoreEmailToDraft)
with a full `mailboxIds` property replacement via a new mailboxIdsReplacement()
helper. This states the actual intent -- after the move the message should
belong to exactly the target mailbox -- and is immune to the pointer-token
bug. Every one of these sites moves a message that Bulwark itself placed
solely in Drafts (or, for undo, in Sent), so the replacement is
behaviour-equivalent. Note it is a replacement: a membership added to the
message by another client between creation and send is not preserved.
restoreEmailToDraft now always lands the message in Drafts only (previously,
when no Sent mailbox id was passed, it left the Sent copy in place); the demo
client is aligned with the same contract.

Add regression tests for the full-replacement shape, a numeric ("0") Drafts id,
and restoreEmailToDraft.

Follow-up (not included here): the send paths still ignore the implicit
Email/set `notUpdated` result of onSuccessUpdateEmail, so any other post-send
filing failure would remain silent.
2026-07-16 17:58:19 +02:00

1189 lines
49 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, _hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
let filtered = this.data.emails;
if (mailboxId) {
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
}
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 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 };
}
// 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') } },
});
}
}