Initial release: JMAP Webmail Client

A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
Designed for Stalwart Mail Server.

Features:
- Full email operations (compose, reply, forward, threading)
- Real-time push notifications
- Dark/light theme support
- Mobile responsive design
- Keyboard shortcuts
- Drag-and-drop organization
- i18n (English/French)
- Security-first (external content blocked, HTML sanitization)
This commit is contained in:
Matthieu MALVACHE
2025-12-10 17:54:22 +01:00
committed by Matthieu MALVACHE
commit cf21a84263
79 changed files with 21821 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
interface AuthState {
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
serverUrl: string | null;
username: string | null;
client: JMAPClient | null;
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
isAuthenticated: false,
isLoading: false,
error: null,
serverUrl: null,
username: null,
client: null,
login: async (serverUrl, username, password) => {
set({ isLoading: true, error: null });
try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, password);
// Try to connect
await client.connect();
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
isLoading: false,
serverUrl,
username,
client,
error: null,
});
return true;
} catch (error) {
console.error('Login error:', error);
let errorKey = 'generic';
// Map common errors to translation keys
if (error instanceof Error) {
if (error.message.includes('Invalid username or password') ||
error.message.includes('401') ||
error.message.includes('Unauthorized')) {
errorKey = 'invalid_credentials';
} else if (error.message.includes('network') ||
error.message.includes('Failed to fetch')) {
errorKey = 'connection_failed';
}
}
set({
isLoading: false,
error: errorKey,
isAuthenticated: false,
client: null,
});
return false;
}
},
logout: () => {
const state = get();
// Disconnect the JMAP client if it exists
if (state.client) {
state.client.disconnect();
}
set({
isAuthenticated: false,
serverUrl: null,
username: null,
client: null,
error: null,
});
// Clear persisted storage
localStorage.removeItem('auth-storage');
// Clear email store state
useEmailStore.setState({
emails: [],
mailboxes: [],
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
error: null,
searchQuery: "",
quota: null,
});
},
checkAuth: async () => {
const state = get();
// If authenticated but no client (e.g., after page refresh), we can't restore the session
// because we don't store passwords for security reasons
if (state.isAuthenticated && !state.client) {
// Reset auth state - user will need to log in again
set({
isAuthenticated: false,
isLoading: false,
client: null,
serverUrl: null,
username: null,
});
}
// Mark loading as complete
set({ isLoading: false });
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({
// Only persist non-sensitive data
serverUrl: state.serverUrl,
username: state.username,
// Don't persist isAuthenticated since we can't restore the session without a password
}),
}
)
);
+984
View File
@@ -0,0 +1,984 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
interface EmailStore {
emails: Email[];
mailboxes: Mailbox[];
selectedEmail: Email | null;
selectedMailbox: string;
isLoading: boolean;
isLoadingEmail: boolean; // Track when a full email is being fetched
isLoadingMore: boolean; // Track when loading more emails (pagination)
error: string | null;
searchQuery: string;
quota: { used: number; total: number } | null;
processingReadStatus: Set<string>; // Track emails being marked as read/unread
selectedEmailIds: Set<string>; // Track selected emails for batch operations
hasMoreEmails: boolean; // Track if more emails are available to load
totalEmails: number; // Total number of emails in the current mailbox/query
isPushConnected: boolean; // Track if push notifications are connected
lastPushUpdate: number | null; // Timestamp of last push update
newEmailNotification: Email | null; // New email notification for toast
// Thread expansion state
expandedThreadIds: Set<string>; // Which threads are expanded in the list
threadEmailsCache: Map<string, Email[]>; // Cache of fully fetched thread emails
isLoadingThread: string | null; // Thread ID currently being loaded
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
selectEmail: (email: Email | null) => void;
selectMailbox: (mailboxId: string) => void;
setLoading: (loading: boolean) => void;
setLoadingEmail: (loading: boolean) => void;
setError: (error: string | null) => void;
setSearchQuery: (query: string) => void;
setQuota: (quota: { used: number; total: number } | null) => void;
toggleEmailSelection: (emailId: string) => void;
selectAllEmails: () => void;
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[], draftId?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string) => 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>;
toggleStar: (client: JMAPClient, 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>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>;
handleNewEmailNotification: (email: Email) => void;
clearNewEmailNotification: () => void;
// Thread expansion actions
toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
// Mock data for demo
loadMockData: () => void;
}
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
isLoadingEmail: false,
isLoadingMore: false,
error: null,
searchQuery: "",
quota: null,
processingReadStatus: new Set(),
selectedEmailIds: new Set(),
hasMoreEmails: false,
totalEmails: 0,
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
// Thread expansion state
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
selectEmail: (email) => set({ selectedEmail: email }),
selectMailbox: (mailboxId) => set({
selectedMailbox: mailboxId,
selectedEmail: null,
selectedEmailIds: new Set(),
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
}),
setLoading: (loading) => set({ isLoading: loading }),
setLoadingEmail: (loading) => set({ isLoadingEmail: loading }),
setError: (error) => set({ error }),
setSearchQuery: (query) => set({ searchQuery: query }),
setQuota: (quota) => set({ quota }),
toggleEmailSelection: (emailId) => {
const { selectedEmailIds } = get();
const newSelection = new Set(selectedEmailIds);
if (newSelection.has(emailId)) {
newSelection.delete(emailId);
} else {
newSelection.add(emailId);
}
set({ selectedEmailIds: newSelection });
},
selectAllEmails: () => {
const { emails } = get();
const allIds = new Set(emails.map(e => e.id));
set({ selectedEmailIds: allIds });
},
clearSelection: () => {
set({ selectedEmailIds: new Set() });
},
// JMAP operations
fetchMailboxes: async (client) => {
set({ isLoading: true, error: null });
try {
const mailboxes = await client.getAllMailboxes();
// Auto-select inbox if no mailbox is currently selected
const currentSelectedMailbox = get().selectedMailbox;
if (!currentSelectedMailbox) {
// Find inbox from PRIMARY account (not shared accounts)
const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared);
if (inboxMailbox) {
set({ mailboxes, selectedMailbox: inboxMailbox.id, isLoading: false });
} else {
set({ mailboxes, isLoading: false });
}
} else {
set({ mailboxes, isLoading: false });
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
isLoading: false
});
}
},
fetchEmails: async (client, mailboxId) => {
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
try {
const targetMailboxId = mailboxId || get().selectedMailbox;
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === targetMailboxId);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
});
} catch (error) {
console.error('Failed to fetch emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0
});
}
},
loadMoreEmails: async (client) => {
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
set({ isLoadingMore: true, error: null });
try {
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length);
set({
emails: [...emails, ...result.emails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false
});
} catch (error) {
console.error('Failed to load more emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false
});
}
},
fetchEmailContent: async (client, emailId) => {
try {
// Find the selected mailbox to determine accountId (for shared folders)
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const email = await client.getEmail(emailId, accountId);
if (email) {
set({ selectedEmail: email });
}
return email;
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch email content"
});
return null;
}
},
fetchQuota: async (client) => {
try {
const quota = await client.getQuota();
set({ quota });
} catch {
// Don't set error state as quota is optional
}
},
sendEmail: async (client, to, subject, body, cc, bcc, draftId) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, draftId);
// Refresh emails after sending
await get().fetchEmails(client);
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
isLoading: false
});
throw error;
}
},
deleteEmail: async (client, emailId) => {
try {
// Get the email to check if it's unread and which mailboxes it belongs to
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isUnread = !email.keywords?.$seen;
// Get delete action preference from settings
const deleteAction = useSettingsStore.getState().deleteAction;
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
// If deleteAction is 'trash', try to move to trash mailbox
if (deleteAction === 'trash') {
// Find trash mailbox for the correct account
const trashMailbox = mailboxes.find(mb => {
if (accountId) {
// For shared folders, match by accountId
return mb.role === 'trash' && mb.accountId === accountId;
}
// For primary account, find trash that's not from a shared folder
return mb.role === 'trash' && !mb.isShared;
});
if (trashMailbox) {
// Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id;
await client.moveToTrash(emailId, trashId, accountId);
// Remove from local state (email moved to trash, not in current view)
set((state) => {
let updatedMailboxes = state.mailboxes;
// Update counters for source mailbox (email leaving)
if (email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
};
}
// Update trash mailbox counters (email arriving)
if (mailbox.id === trashMailbox.id) {
return {
...mailbox,
totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
};
}
return mailbox;
});
}
return {
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
mailboxes: updatedMailboxes
};
});
return;
}
// If no trash mailbox found, fall through to permanent delete
}
// Permanent delete
await client.deleteEmail(emailId);
// Remove from local state and update mailbox counters if needed
set((state) => {
let updatedMailboxes = state.mailboxes;
// If the email was unread, decrement the unread counters
if (isUnread && email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: Math.max(0, mailbox.unreadEmails - 1),
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: Math.max(0, mailbox.unreadThreads - 1)
};
}
return mailbox;
});
} else if (email.mailboxIds) {
// If email was read, only decrement total counters
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
totalThreads: Math.max(0, mailbox.totalThreads - 1)
};
}
return mailbox;
});
}
return {
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
mailboxes: updatedMailboxes
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to delete email"
});
throw error;
}
},
markAsRead: async (client, emailId, read) => {
try {
// Check if this email is already being processed
const processingKey = `${emailId}-${read}`;
const currentProcessing = get().processingReadStatus;
if (currentProcessing.has(processingKey)) {
return; // Already being processed
}
// Get the email to check its current state and mailboxes
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
// Check if already in the desired state
const isCurrentlyRead = email.keywords?.$seen === true;
if (isCurrentlyRead === read) {
return; // Already in desired state
}
// Add to processing set
set((state) => ({
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
}));
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
await client.markAsRead(emailId, read, accountId);
// Update local state including mailbox counters
set((state) => {
// Remove from processing set
const newProcessingSet = new Set(state.processingReadStatus);
newProcessingSet.delete(processingKey);
// Only update counters if the state is actually changing
const emailInState = state.emails.find(e => e.id === emailId);
if (!emailInState) return { processingReadStatus: newProcessingSet };
const wasRead = emailInState.keywords?.$seen === true;
if (wasRead === read) {
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
}
const updatedMailboxes = state.mailboxes.map(mailbox => {
// Check if this email belongs to this mailbox
if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
const delta = read ? -1 : 1;
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
unreadThreads: Math.max(0, mailbox.unreadThreads + delta)
};
}
return mailbox;
});
return {
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
mailboxes: updatedMailboxes,
processingReadStatus: newProcessingSet
};
});
} catch (error) {
// Remove from processing set on error
set((state) => {
const newProcessingSet = new Set(state.processingReadStatus);
newProcessingSet.delete(`${emailId}-${read}`);
return {
processingReadStatus: newProcessingSet,
error: error instanceof Error ? error.message : "Failed to update email"
};
});
throw error;
}
},
moveToMailbox: async (client, emailId, destinationMailboxId) => {
try {
// Get the email to check its current mailboxes and read status
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isUnread = !email.keywords?.$seen;
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
await client.moveEmail(emailId, destinationMailboxId);
// Update local state and mailbox counters
set((state) => {
// Update mailbox counters
const updatedMailboxes = state.mailboxes.map(mailbox => {
// Remove from current mailboxes
if (currentMailboxIds.includes(mailbox.id)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
};
}
// Add to destination mailbox
else if (mailbox.id === destinationMailboxId) {
return {
...mailbox,
totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
};
}
return mailbox;
});
// Update the email's mailboxIds
const updatedEmails = state.emails.map(e => {
if (e.id === emailId) {
return {
...e,
mailboxIds: { [destinationMailboxId]: true }
};
}
return e;
});
// If moved email is selected, update selectedEmail too
const updatedSelectedEmail = state.selectedEmail?.id === emailId
? { ...state.selectedEmail, mailboxIds: { [destinationMailboxId]: true } }
: state.selectedEmail;
return {
emails: updatedEmails,
selectedEmail: updatedSelectedEmail,
mailboxes: updatedMailboxes
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move email"
});
throw error;
}
},
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
const emails = await client.searchEmails(query);
set({ emails, isLoading: false, hasMoreEmails: false, totalEmails: emails.length });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0
});
}
},
toggleStar: async (client, emailId) => {
try {
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isFlagged = email.keywords.$flagged || false;
await client.toggleStar(emailId, !isFlagged);
// Update local state
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } }
: state.selectedEmail
}));
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to update star"
});
throw error;
}
},
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMarkAsRead(emailIdsArray, read);
// Update local state
const updatedEmails = emails.map(email =>
selectedEmailIds.has(email.id)
? { ...email, keywords: { ...email.keywords, $seen: read } }
: email
);
// Update mailbox counters
const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaUnread = 0;
affectedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
const wasRead = email.keywords?.$seen === true;
if (wasRead !== read) {
deltaUnread += read ? -1 : 1;
}
}
});
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread)
};
});
set({
emails: updatedEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to update emails",
isLoading: false
});
}
},
batchDelete: async (client) => {
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchDeleteEmails(emailIdsArray);
// Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
// Update mailbox counters
const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
deltaTotalEmails--;
if (!email.keywords?.$seen) {
deltaUnreadEmails--;
}
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails)
};
});
set({
emails: remainingEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to delete emails",
isLoading: false
});
}
},
batchMoveToMailbox: async (client, toMailboxId) => {
const { selectedEmailIds, emails } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchMoveEmails(emailIdsArray, toMailboxId);
// Update local state - remove from current view since they moved
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
set({
emails: remainingEmails,
selectedEmailIds: new Set(),
isLoading: false
});
// Refresh emails to get updated list
await get().fetchEmails(client, get().selectedMailbox);
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move emails",
isLoading: false
});
}
},
// Push notification handlers
setPushConnected: (connected) => {
set({ isPushConnected: connected });
},
handleStateChange: async (change, client) => {
try {
// Update last push update timestamp
set({ lastPushUpdate: Date.now() });
// Get the current account ID from the client (assuming primary account)
const accountId = client.getAccountId();
// Check if there are changes for this account
const accountChanges = change.changed[accountId];
if (!accountChanges) return;
// Handle Email state changes - refresh current mailbox
if (accountChanges.Email) {
await get().refreshCurrentMailbox(client);
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
await get().fetchMailboxes(client);
}
// Could also handle Thread, EmailSubmission, Identity changes in the future
} catch (error) {
console.error('Failed to handle state change:', error);
set({
error: error instanceof Error ? error.message : "Failed to handle push notification"
});
}
},
refreshCurrentMailbox: async (client) => {
const { selectedMailbox } = get();
// Only refresh if a mailbox is currently selected
if (!selectedMailbox) return;
try {
// Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
// Check if there are new emails by comparing the first email ID
const currentFirstEmailId = get().emails[0]?.id;
const newFirstEmailId = result.emails[0]?.id;
// If the first email changed, we have a new email - trigger notification
if (currentFirstEmailId !== newFirstEmailId && result.emails[0]) {
get().handleNewEmailNotification(result.emails[0]);
}
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total
});
} catch (error) {
console.error('Failed to refresh current mailbox:', error);
// Don't set error state for background refreshes to avoid disrupting the UI
}
},
handleNewEmailNotification: (email) => {
// Set the new email notification state
// This can be consumed by a toast component
set({ newEmailNotification: email });
},
clearNewEmailNotification: () => {
set({ newEmailNotification: null });
},
// Thread expansion actions
toggleThreadExpansion: (threadId) => {
const { expandedThreadIds } = get();
const newExpandedThreadIds = new Set(expandedThreadIds);
if (newExpandedThreadIds.has(threadId)) {
newExpandedThreadIds.delete(threadId);
} else {
newExpandedThreadIds.add(threadId);
}
set({ expandedThreadIds: newExpandedThreadIds });
},
fetchThreadEmails: async (client, threadId) => {
const { threadEmailsCache, selectedMailbox, mailboxes } = get();
// Check if we already have this thread cached
const cachedEmails = threadEmailsCache.get(threadId);
if (cachedEmails && cachedEmails.length > 0) {
return cachedEmails;
}
// Set loading state
set({ isLoadingThread: threadId });
try {
// Determine accountId for shared folders
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Fetch all emails in the thread
const emails = await client.getThreadEmails(threadId, accountId);
// Update cache
const newCache = new Map(get().threadEmailsCache);
newCache.set(threadId, emails);
set({
threadEmailsCache: newCache,
isLoadingThread: null
});
return emails;
} catch (error) {
console.error('Failed to fetch thread emails:', error);
set({ isLoadingThread: null });
return [];
}
},
collapseAllThreads: () => {
set({
expandedThreadIds: new Set(),
isLoadingThread: null
});
},
updateThreadCache: (threadId, emails) => {
const newCache = new Map(get().threadEmailsCache);
newCache.set(threadId, emails);
set({ threadEmailsCache: newCache });
},
loadMockData: () => {
const mockEmails: Email[] = [
{
id: "1",
threadId: "thread-1",
mailboxIds: { inbox: true },
keywords: { $seen: false },
size: 1024,
receivedAt: new Date().toISOString(),
from: [{ name: "Alice Johnson", email: "alice@example.com" }],
to: [{ email: "you@example.com" }],
subject: "Q4 Budget Review Meeting",
preview: "Hi team, I wanted to schedule a meeting to review our Q4 budget projections. Are you available this Thursday at 2 PM? We need to discuss...",
hasAttachment: true,
},
{
id: "2",
threadId: "thread-2",
mailboxIds: { inbox: true },
keywords: { $seen: true, $flagged: true },
size: 512,
receivedAt: new Date(Date.now() - 3600000).toISOString(),
from: [{ name: "Bob Smith", email: "bob@company.com" }],
to: [{ email: "you@example.com" }],
subject: "Re: Project Timeline Update",
preview: "Thanks for the update. The new timeline looks good to me. I've reviewed the milestones and everything seems achievable...",
hasAttachment: false,
},
{
id: "3",
threadId: "thread-3",
mailboxIds: { inbox: true },
keywords: { $seen: false },
size: 2048,
receivedAt: new Date(Date.now() - 7200000).toISOString(),
from: [{ name: "Carol White", email: "carol@design.co" }],
to: [{ email: "you@example.com" }],
subject: "New Design Mockups Ready",
preview: "Hey! The new mockups for the landing page are ready for review. I've incorporated all the feedback from last week's meeting...",
hasAttachment: true,
},
{
id: "4",
threadId: "thread-4",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 768,
receivedAt: new Date(Date.now() - 86400000).toISOString(),
from: [{ name: "GitHub", email: "notifications@github.com" }],
to: [{ email: "you@example.com" }],
subject: "[PR] Feature: Add authentication module",
preview: "A new pull request has been opened in your repository. This PR adds a comprehensive authentication module with OAuth support...",
hasAttachment: false,
},
{
id: "5",
threadId: "thread-5",
mailboxIds: { inbox: true },
keywords: { $seen: true },
size: 1536,
receivedAt: new Date(Date.now() - 172800000).toISOString(),
from: [{ name: "David Lee", email: "david@startup.io" }],
to: [{ email: "you@example.com" }],
subject: "Investment Proposal Discussion",
preview: "Following up on our call yesterday, I'm sending over the investment proposal we discussed. The terms are quite favorable...",
hasAttachment: true,
},
];
const mockMailboxes: Mailbox[] = [
{
id: "inbox",
name: "Inbox",
role: "inbox",
sortOrder: 1,
totalEmails: 5,
unreadEmails: 2,
totalThreads: 5,
unreadThreads: 2,
myRights: {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
},
isSubscribed: true,
},
];
set({
emails: mockEmails,
mailboxes: mockMailboxes,
});
},
}));
+19
View File
@@ -0,0 +1,19 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface LocaleStore {
locale: string;
setLocale: (locale: string) => void;
}
export const useLocaleStore = create<LocaleStore>()(
persist(
(set) => ({
locale: 'en',
setLocale: (locale) => set({ locale }),
}),
{
name: 'locale-storage',
}
)
);
+213
View File
@@ -0,0 +1,213 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type FontSize = 'small' | 'medium' | 'large';
export type ListDensity = 'compact' | 'regular' | 'comfortable';
export type DeleteAction = 'trash' | 'permanent';
export type ReplyMode = 'reply' | 'replyAll';
export type DateFormat = 'regional' | 'iso' | 'custom';
export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
interface SettingsState {
// Appearance
fontSize: FontSize;
listDensity: ListDensity;
animationsEnabled: boolean;
// Language & Region
dateFormat: DateFormat;
timeFormat: TimeFormat;
firstDayOfWeek: FirstDayOfWeek;
// Email Behavior
markAsReadDelay: number; // milliseconds (0 = instant, -1 = never)
deleteAction: DeleteAction;
showPreview: boolean;
emailsPerPage: number;
externalContentPolicy: ExternalContentPolicy;
// Composer
autoSaveDraftInterval: number; // milliseconds
sendConfirmation: boolean;
defaultReplyMode: ReplyMode;
// Privacy & Security
sessionTimeout: number; // minutes (0 = never)
// Advanced
debugMode: boolean;
// Actions
updateSetting: <K extends keyof SettingsState>(
key: K,
value: SettingsState[K]
) => void;
resetToDefaults: () => void;
exportSettings: () => string;
importSettings: (json: string) => boolean;
}
const DEFAULT_SETTINGS = {
// Appearance
fontSize: 'medium' as FontSize,
listDensity: 'regular' as ListDensity,
animationsEnabled: true,
// Language & Region
dateFormat: 'regional' as DateFormat,
timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
// Email Behavior
markAsReadDelay: 0, // Instant
deleteAction: 'trash' as DeleteAction,
showPreview: true,
emailsPerPage: 50,
externalContentPolicy: 'ask' as ExternalContentPolicy,
// Composer
autoSaveDraftInterval: 60000, // 1 minute
sendConfirmation: false,
defaultReplyMode: 'reply' as ReplyMode,
// Privacy & Security
sessionTimeout: 0, // Never
// Advanced
debugMode: false,
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set, get) => ({
...DEFAULT_SETTINGS,
updateSetting: (key, value) => {
set({ [key]: value });
// Apply font size to document root
if (key === 'fontSize') {
applyFontSize(value as FontSize);
}
// Apply list density to document root
if (key === 'listDensity') {
applyListDensity(value as ListDensity);
}
// Apply animations to document root
if (key === 'animationsEnabled') {
applyAnimations(value as boolean);
}
},
resetToDefaults: () => {
set(DEFAULT_SETTINGS);
applyFontSize(DEFAULT_SETTINGS.fontSize);
applyListDensity(DEFAULT_SETTINGS.listDensity);
applyAnimations(DEFAULT_SETTINGS.animationsEnabled);
},
exportSettings: () => {
const state = get();
const settings = {
fontSize: state.fontSize,
listDensity: state.listDensity,
animationsEnabled: state.animationsEnabled,
dateFormat: state.dateFormat,
timeFormat: state.timeFormat,
firstDayOfWeek: state.firstDayOfWeek,
markAsReadDelay: state.markAsReadDelay,
deleteAction: state.deleteAction,
showPreview: state.showPreview,
emailsPerPage: state.emailsPerPage,
externalContentPolicy: state.externalContentPolicy,
autoSaveDraftInterval: state.autoSaveDraftInterval,
sendConfirmation: state.sendConfirmation,
defaultReplyMode: state.defaultReplyMode,
sessionTimeout: state.sessionTimeout,
debugMode: state.debugMode,
};
return JSON.stringify(settings, null, 2);
},
importSettings: (json: string) => {
try {
const settings = JSON.parse(json);
// Validate settings
if (typeof settings !== 'object' || settings === null) {
return false;
}
// Apply settings
Object.keys(settings).forEach((key) => {
if (key in DEFAULT_SETTINGS) {
set({ [key]: settings[key] });
}
});
// Apply visual settings
applyFontSize(get().fontSize);
applyListDensity(get().listDensity);
applyAnimations(get().animationsEnabled);
return true;
} catch (error) {
console.error('Failed to import settings:', error);
return false;
}
},
}),
{
name: 'settings-storage',
version: 1,
}
)
);
// Helper functions to apply settings to DOM
function applyFontSize(size: FontSize) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const sizeMap = {
small: '14px',
medium: '16px',
large: '18px',
};
root.style.setProperty('--font-size-base', sizeMap[size]);
}
function applyListDensity(density: ListDensity) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const densityMap = {
compact: '32px',
regular: '48px',
comfortable: '64px',
};
root.style.setProperty('--list-item-height', densityMap[density]);
}
function applyAnimations(enabled: boolean) {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (enabled) {
root.style.removeProperty('--transition-duration');
} else {
root.style.setProperty('--transition-duration', '0s');
}
}
// Initialize settings on load
if (typeof window !== 'undefined') {
const store = useSettingsStore.getState();
applyFontSize(store.fontSize);
applyListDensity(store.listDensity);
applyAnimations(store.animationsEnabled);
}
+89
View File
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark' | 'system';
interface ThemeState {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
initializeTheme: () => void;
}
const getSystemTheme = (): 'light' | 'dark' => {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const applyTheme = (theme: 'light' | 'dark') => {
if (typeof document === 'undefined') return;
const root = document.documentElement;
// Ensure both classes are handled properly
if (theme === 'dark') {
root.classList.remove('light');
root.classList.add('dark');
} else {
root.classList.remove('dark');
root.classList.add('light');
}
// Store in localStorage for immediate access
localStorage.setItem('theme-applied', theme);
};
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
resolvedTheme: 'light',
setTheme: (theme) => {
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
set({ theme, resolvedTheme });
},
toggleTheme: () => {
const { theme } = get();
const nextTheme: Theme =
theme === 'light' ? 'dark' :
theme === 'dark' ? 'system' : 'light';
get().setTheme(nextTheme);
},
initializeTheme: () => {
const { theme } = get();
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
applyTheme(resolvedTheme);
set({ resolvedTheme });
// Listen for system theme changes
if (typeof window !== 'undefined') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
const { theme } = get();
if (theme === 'system') {
const newResolvedTheme = getSystemTheme();
applyTheme(newResolvedTheme);
set({ resolvedTheme: newResolvedTheme });
}
};
// Modern browsers
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handleChange);
} else {
// Fallback for older browsers
mediaQuery.addListener(handleChange);
}
}
},
}),
{
name: 'theme-storage',
partialize: (state) => ({ theme: state.theme }),
}
)
);
+52
View File
@@ -0,0 +1,52 @@
import { create } from "zustand";
import { Toast } from "@/components/ui/toast";
interface ToastStore {
toasts: Toast[];
addToast: (toast: Omit<Toast, "id">) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
}
export const useToastStore = create<ToastStore>((set) => ({
toasts: [],
addToast: (toast) => {
const id = Math.random().toString(36).substring(2, 11);
const newToast: Toast = {
...toast,
id,
duration: toast.duration ?? 5000, // Default 5 seconds
};
set((state) => ({
toasts: [...state.toasts, newToast],
}));
},
removeToast: (id) => {
set((state) => ({
toasts: state.toasts.filter((toast) => toast.id !== id),
}));
},
clearToasts: () => {
set({ toasts: [] });
},
}));
// Helper functions for common toast types
export const toast = {
success: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "success", title, message });
},
error: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "error", title, message, duration: 10000 });
},
info: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "info", title, message });
},
warning: (title: string, message?: string) => {
useToastStore.getState().addToast({ type: "warning", title, message });
},
};
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { create } from "zustand";
export type ActiveView = "sidebar" | "list" | "viewer";
interface UIState {
// Mobile view state
activeView: ActiveView;
sidebarOpen: boolean;
// Device detection (hydrated client-side)
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
// Actions
setActiveView: (view: ActiveView) => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
// Navigation helpers
showEmailList: () => void;
showEmailViewer: () => void;
goBack: () => void;
}
export const useUIStore = create<UIState>((set, get) => ({
// Initial state (SSR-safe defaults)
activeView: "list",
sidebarOpen: false,
isMobile: false,
isTablet: false,
isDesktop: true,
// Actions
setActiveView: (view) => set({ activeView: view }),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setDeviceType: (isMobile, isTablet, isDesktop) =>
set({ isMobile, isTablet, isDesktop }),
// Navigation helpers for mobile
showEmailList: () => {
const { isMobile } = get();
if (isMobile) {
set({ activeView: "list", sidebarOpen: false });
}
},
showEmailViewer: () => {
const { isMobile } = get();
if (isMobile) {
set({ activeView: "viewer" });
}
},
goBack: () => {
const { activeView, isMobile } = get();
if (!isMobile) return;
if (activeView === "viewer") {
set({ activeView: "list" });
} else if (activeView === "list") {
set({ sidebarOpen: true });
}
},
}));