feat: add demo data for emails, files, filters, identities, mailboxes, vacation responses, and JMAP client interface

- Created demo emails with various states (inbox, sent, drafts, trash, etc.) in `emails.ts`.
- Added demo file nodes representing directories and files in `files.ts`.
- Implemented demo Sieve capabilities and scripts in `filters.ts`.
- Defined demo identities for users in `identities.ts`.
- Established demo mailboxes with permissions and counts in `mailboxes.ts`.
- Created a demo vacation response in `vacation.ts`.
- Introduced a comprehensive JMAP client interface in `client-interface.ts` to standardize interactions with the JMAP API.
This commit is contained in:
Linus Rath
2026-03-21 01:38:42 +01:00
parent a8be40579e
commit 2547c10060
53 changed files with 4036 additions and 110 deletions
+102 -2
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store';
@@ -21,7 +22,7 @@ interface AuthState {
error: string | null;
serverUrl: string | null;
username: string | null;
client: JMAPClient | null;
client: IJMAPClient | null;
identities: Identity[];
primaryIdentity: Identity | null;
authMode: 'basic' | 'oauth';
@@ -30,9 +31,11 @@ interface AuthState {
tokenExpiresAt: number | null;
connectionLost: boolean;
activeAccountId: string | null;
isDemoMode: boolean;
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
loginDemo: () => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>;
logout: () => Promise<void>;
logoutAll: () => void;
@@ -140,7 +143,7 @@ function markSessionExpired(): void {
saveRedirectAfterLogin();
}
function initializeFeatureStores(client: JMAPClient): void {
function initializeFeatureStores(client: IJMAPClient): void {
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
@@ -242,6 +245,7 @@ export const useAuthStore = create<AuthState>()(
tokenExpiresAt: null,
connectionLost: false,
activeAccountId: null,
isDemoMode: false,
login: async (serverUrl, username, password, totp, rememberMe) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
@@ -355,6 +359,68 @@ export const useAuthStore = create<AuthState>()(
}
},
loginDemo: async () => {
set({ isLoading: true, error: null });
try {
// Clear all store data before re-initializing with fresh demo data
clearAllStores();
const { DemoJMAPClient } = await import('@/lib/demo/demo-client');
const client = new DemoJMAPClient();
await client.connect();
const username = client.getUsername();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
// Register a demo account entry so the account-switcher shows
// proper avatar/name instead of a "?" placeholder.
const accountStore = useAccountStore.getState();
const demoAccountId = accountStore.addAccount({
label: primaryIdentity?.name || 'Demo User',
serverUrl: 'https://demo.example.com',
username,
authMode: 'basic',
rememberMe: false,
displayName: primaryIdentity?.name || 'Demo User',
email: primaryIdentity?.email || username,
lastLoginAt: Date.now(),
isConnected: true,
hasError: false,
isDefault: true,
});
accountStore.setActiveAccount(demoAccountId);
set({
isAuthenticated: true,
isLoading: false,
serverUrl: 'demo.example.com',
username,
client,
identities,
primaryIdentity,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
error: null,
activeAccountId: demoAccountId,
isDemoMode: true,
});
return true;
} catch (error) {
debug.error('Demo login error:', error);
set({
isLoading: false,
error: 'generic',
isAuthenticated: false,
client: null,
});
return false;
}
},
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
set({ isLoading: true, error: null });
@@ -512,12 +578,39 @@ export const useAuthStore = create<AuthState>()(
logout: async () => {
const state = get();
const wasDemoMode = state.isDemoMode;
const wasOAuth = state.authMode === 'oauth';
const accountId = state.activeAccountId;
const accountStore = useAccountStore.getState();
const account = accountId ? accountStore.getAccountById(accountId) : null;
const slot = account?.cookieSlot ?? 0;
// Demo mode: simple cleanup, no network calls
if (wasDemoMode) {
set({ client: null });
state.client?.disconnect();
set({
isAuthenticated: false,
serverUrl: null,
username: null,
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
rememberMe: false,
accessToken: null,
tokenExpiresAt: null,
connectionLost: false,
error: null,
activeAccountId: null,
isDemoMode: false,
});
localStorage.removeItem('auth-storage');
clearAllStores();
redirectToLogin();
return;
}
clearRefreshTimer(accountId ?? undefined);
// Null out the client BEFORE disconnecting so the page doesn't fire
@@ -902,6 +995,13 @@ export const useAuthStore = create<AuthState>()(
const accountStore = useAccountStore.getState();
const accounts = accountStore.accounts;
// If the only account is the demo account, re-initialize demo mode
// instead of trying to restore a server session (which doesn't exist).
if (accounts.length === 1 && accounts[0].serverUrl === 'https://demo.example.com') {
await get().loginDemo();
return;
}
// Multi-account restoration: restore all registered accounts
if (accounts.length > 0) {
// Null out client so the page doesn't fire data-loading effects
+16 -16
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
import { debug } from '@/lib/debug';
import { normalizeAllDayDuration } from '@/lib/calendar-utils';
@@ -37,17 +37,17 @@ interface CalendarStore {
dateRange: { start: string; end: string } | null;
setSupported: (supported: boolean) => void;
fetchCalendars: (client: JMAPClient) => Promise<void>;
fetchEvents: (client: JMAPClient, start: string, end: string) => Promise<void>;
createEvent: (client: JMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: JMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: JMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: JMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: JMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: JMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: JMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: JMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: JMAPClient, calendarId: string) => Promise<number>;
fetchCalendars: (client: IJMAPClient) => Promise<void>;
fetchEvents: (client: IJMAPClient, start: string, end: string) => Promise<void>;
createEvent: (client: IJMAPClient, event: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<CalendarEvent | null>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<void>;
deleteEvent: (client: IJMAPClient, id: string, sendSchedulingMessages?: boolean) => Promise<void>;
rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record<string, string> | null) => Promise<void>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string) => Promise<number>;
updateCalendar: (client: IJMAPClient, calendarId: string, updates: Partial<Calendar>) => Promise<void>;
createCalendar: (client: IJMAPClient, calendar: Partial<Calendar>) => Promise<Calendar | null>;
removeCalendar: (client: IJMAPClient, calendarId: string) => Promise<void>;
clearCalendarEvents: (client: IJMAPClient, calendarId: string) => Promise<number>;
setSelectedDate: (date: Date) => void;
setViewMode: (mode: CalendarViewMode) => void;
toggleCalendarVisibility: (calendarId: string) => void;
@@ -56,10 +56,10 @@ interface CalendarStore {
// iCal subscriptions
icalSubscriptions: ICalSubscription[];
addICalSubscription: (client: JMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
removeICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: JMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: JMAPClient) => Promise<void>;
addICalSubscription: (client: IJMAPClient, url: string, name: string, color: string, refreshInterval?: number) => Promise<ICalSubscription | null>;
removeICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshICalSubscription: (client: IJMAPClient, subscriptionId: string) => Promise<void>;
refreshAllSubscriptions: (client: IJMAPClient) => Promise<void>;
isSubscriptionCalendar: (calendarId: string) => boolean;
}
+15 -15
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export function getContactDisplayName(contact: ContactCard): string {
if (contact.name?.components) {
@@ -47,11 +47,11 @@ interface ContactStore {
lastSelectedContactId: string | null;
activeTab: 'all' | 'groups';
fetchContacts: (client: JMAPClient) => Promise<void>;
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: JMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: JMAPClient, id: string) => Promise<void>;
fetchContacts: (client: IJMAPClient) => Promise<void>;
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
createContact: (client: IJMAPClient, contact: Partial<ContactCard>) => Promise<void>;
updateContact: (client: IJMAPClient, id: string, updates: Partial<ContactCard>) => Promise<void>;
deleteContact: (client: IJMAPClient, id: string) => Promise<void>;
addLocalContact: (contact: ContactCard) => void;
updateLocalContact: (id: string, updates: Partial<ContactCard>) => void;
@@ -68,21 +68,21 @@ interface ContactStore {
getGroups: () => ContactCard[];
getIndividuals: () => ContactCard[];
getGroupMembers: (groupId: string) => ContactCard[];
createGroup: (client: JMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
updateGroup: (client: JMAPClient | null, groupId: string, name: string) => Promise<void>;
addMembersToGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
removeMembersFromGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
createGroup: (client: IJMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
updateGroup: (client: IJMAPClient | null, groupId: string, name: string) => Promise<void>;
addMembersToGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
removeMembersFromGroup: (client: IJMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
deleteGroup: (client: IJMAPClient | null, groupId: string) => Promise<void>;
toggleContactSelection: (id: string) => void;
selectRangeContacts: (targetId: string, sortedIds: string[]) => void;
selectAllContacts: (ids: string[]) => void;
clearSelection: () => void;
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: JMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
bulkDeleteContacts: (client: IJMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: IJMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
moveContactToAddressBook: (client: IJMAPClient, contactIds: string[], addressBook: AddressBook) => Promise<void>;
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
}
export const useContactStore = create<ContactStore>()(
+32 -32
View File
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
@@ -48,7 +48,7 @@ interface EmailStore {
setSearchQuery: (query: string) => void;
setQuota: (quota: { used: number; total: number } | null) => void;
selectKeyword: (keyword: string | null) => void;
fetchTagCounts: (client: JMAPClient) => Promise<void>;
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null;
@@ -56,54 +56,54 @@ interface EmailStore {
clearSelection: () => void;
// JMAP operations
fetchMailboxes: (client: JMAPClient) => Promise<void>;
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>;
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, 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 }>) => Promise<void>;
sendRawEmail: (client: JMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
advancedSearch: (client: JMAPClient) => Promise<void>;
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
sendEmail: (client: IJMAPClient, 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 }>) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
// Batch operations
batchMarkAsRead: (client: JMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: JMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: IJMAPClient) => Promise<void>;
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>;
handleStateChange: (change: StateChange, client: IJMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: IJMAPClient) => Promise<void>;
handleNewEmailNotification: (email: Email) => void;
clearNewEmailNotification: () => void;
// Thread expansion actions
toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
// Mailbox management
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<void>;
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
// Mock data for demo
loadMockData: () => void;
@@ -1033,7 +1033,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
batchUndoSpam: async (client: JMAPClient, emailIds: string[]) => {
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => {
const { mailboxes, selectedMailbox } = get();
// Find inbox (batch operations don't preserve original mailboxes)
@@ -1521,4 +1521,4 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
mailboxes: mockMailboxes,
});
},
}));
}));
+4 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
export interface FileResource {
@@ -47,7 +47,7 @@ interface FileState {
supportsFiles: boolean | null;
selectedResources: Set<string>;
uploadProgress: UploadProgress | null;
client: JMAPClient | null;
client: IJMAPClient | null;
clipboard: ClipboardState | null;
uploadAbortController: AbortController | null;
favorites: string[];
@@ -55,7 +55,7 @@ interface FileState {
lastAction: UndoAction | null;
// Actions
initClient: (client: JMAPClient) => void;
initClient: (client: IJMAPClient) => void;
checkSupport: () => Promise<boolean>;
navigate: (parentId: string | null, name?: string) => Promise<void>;
navigateByPath: (path: string) => Promise<void>;
@@ -178,7 +178,7 @@ export const useFileStore = create<FileState>((set, get) => ({
try { return JSON.parse(localStorage.getItem('files-recent-files') || '[]'); } catch { return []; }
})(),
initClient: (client: JMAPClient) => {
initClient: (client: IJMAPClient) => {
set({ client });
},
+4 -4
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FilterRule, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { parseScript } from '@/lib/sieve/parser';
import { generateScript } from '@/lib/sieve/generator';
@@ -17,9 +17,9 @@ interface FilterStore {
rawScript: string;
setSupported: (supported: boolean) => void;
fetchFilters: (client: JMAPClient) => Promise<void>;
saveFilters: (client: JMAPClient) => Promise<void>;
validateScript: (client: JMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
fetchFilters: (client: IJMAPClient) => Promise<void>;
saveFilters: (client: IJMAPClient) => Promise<void>;
validateScript: (client: IJMAPClient, content: string) => Promise<{ isValid: boolean; errors?: string[] }>;
addRule: (rule: FilterRule) => void;
updateRule: (ruleId: string, updates: Partial<FilterRule>) => void;
deleteRule: (ruleId: string) => void;
+3 -3
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface VacationStore {
isEnabled: boolean;
@@ -13,8 +13,8 @@ interface VacationStore {
error: string | null;
isSupported: boolean;
fetchVacationResponse: (client: JMAPClient) => Promise<void>;
updateVacationResponse: (client: JMAPClient, updates: {
fetchVacationResponse: (client: IJMAPClient) => Promise<void>;
updateVacationResponse: (client: IJMAPClient, updates: {
isEnabled?: boolean;
fromDate?: string | null;
toDate?: string | null;