2885 lines
112 KiB
TypeScript
2885 lines
112 KiB
TypeScript
import { create } from "zustand";
|
||
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult } from "@/lib/jmap/types";
|
||
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
|
||
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";
|
||
import { emailHooks } from "@/lib/plugin-hooks";
|
||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
||
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||
import { useAuthStore } from "@/stores/auth-store";
|
||
import { useAccountStore } from "@/stores/account-store";
|
||
|
||
type ScheduledSubmissionMetadata = {
|
||
submissionId: string;
|
||
sendAt: string;
|
||
identityId: string;
|
||
undoStatus: 'pending' | 'final' | 'canceled';
|
||
};
|
||
|
||
const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__';
|
||
|
||
type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean };
|
||
|
||
interface EmailStore {
|
||
emails: Email[];
|
||
mailboxes: Mailbox[];
|
||
/**
|
||
* Mailbox caches keyed by accountId. Populated for every connected account
|
||
* when the Pro shell is active so the sidebar can render per-account groups
|
||
* Thunderbird-style. The active account's mailboxes still live in
|
||
* `mailboxes` for back-compat with the single-account view.
|
||
*/
|
||
accountMailboxes: Record<string, Mailbox[]>;
|
||
/**
|
||
* When set, the mail view is reading from this account instead of the
|
||
* global active one. `null` means "use the global active account" - i.e.
|
||
* the standard single-account behavior. Selecting a folder under a
|
||
* non-active account in the Pro sidebar updates this without changing
|
||
* `useAuthStore.activeAccountId`.
|
||
*/
|
||
viewingAccountId: string | null;
|
||
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>;
|
||
threadEmailsCache: Map<string, Email[]>;
|
||
isLoadingThread: string | null;
|
||
|
||
// Keyword/tag filter
|
||
selectedKeyword: string | null;
|
||
tagCounts: Record<string, { total: number; unread: number }>;
|
||
|
||
// Advanced search state
|
||
searchFilters: SearchFilters;
|
||
isAdvancedSearchOpen: boolean;
|
||
searchAbortController: AbortController | null;
|
||
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
|
||
externalSearchResults: ExternalSearchResult[];
|
||
|
||
// Unified mailbox state
|
||
isUnifiedView: boolean;
|
||
unifiedRole: UnifiedMailboxRole | null;
|
||
unifiedErrors: Map<string, string>; // accountId -> error message
|
||
unifiedCounts: UnifiedMailboxCounts[];
|
||
|
||
// Scheduled send state
|
||
scheduledEmails: ScheduledEmail[];
|
||
scheduledEmailIds: Set<string>;
|
||
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>;
|
||
scheduledTotal: number;
|
||
scheduledHasMore: boolean;
|
||
scheduledNextPosition: number;
|
||
isLoadingScheduled: boolean;
|
||
isScheduledView: boolean;
|
||
pendingUndoSend: PendingUndoSend | null;
|
||
|
||
setEmails: (emails: Email[]) => void;
|
||
setMailboxes: (mailboxes: Mailbox[]) => void;
|
||
/** Cache or update the mailbox list for a specific account. */
|
||
setAccountMailboxes: (accountId: string, mailboxes: Mailbox[]) => void;
|
||
/** Wipe the per-account mailbox cache (e.g. on logout). */
|
||
clearAccountMailboxes: () => void;
|
||
setViewingAccount: (accountId: string | null) => void;
|
||
/**
|
||
* Atomic version of (setViewingAccount + selectMailbox). Pass `null` for
|
||
* the active account; pass an accountId to view a non-active account's
|
||
* folder without changing the global active account.
|
||
*/
|
||
selectAccountMailbox: (accountId: string | null, mailboxId: string) => void;
|
||
/**
|
||
* Fetch mailboxes via the supplied client and store them under
|
||
* `accountMailboxes[accountId]`. Used by the Pro shell to populate the
|
||
* sidebar's per-account groups for every connected account.
|
||
*/
|
||
fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise<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;
|
||
selectKeyword: (keyword: string | null) => void;
|
||
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
|
||
toggleEmailSelection: (emailId: string) => void;
|
||
selectRangeEmails: (targetEmailId: string) => void;
|
||
lastSelectedEmailId: string | null;
|
||
selectAllEmails: () => void;
|
||
clearSelection: () => void;
|
||
|
||
// JMAP operations
|
||
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
|
||
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
|
||
// Eager post-login bootstrap: fires mailboxes/quota/emails so the round-trips
|
||
// overlap with Next's soft-nav + home-page hydration. Safe to call multiple
|
||
// times; later calls are no-ops while a prior one is in flight.
|
||
prefetchInitialData: (client: IJMAPClient) => 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; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string) => Promise<SendEmailResult>;
|
||
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
||
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>;
|
||
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise<void>;
|
||
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||
/**
|
||
* Move emails across JMAP accounts. JMAP has no native cross-account move,
|
||
* so for each email we fetch the source's raw RFC822 blob, import it into
|
||
* the destination account's target mailbox, then delete the original.
|
||
* `emailIdsBySource` maps each source accountId to the emails it owns;
|
||
* pass the active account's id explicitly (no `__default__` sentinel).
|
||
* `destMailboxId` is the raw JMAP id on the destination server (not the
|
||
* `accountId:mailboxId` namespace used for shared folders).
|
||
* `destJmapAccountId` overrides the destination client's primary account
|
||
* for the import — used when dropping into a delegated/shared mailbox that
|
||
* is owned by a different JMAP account but accessed through the same
|
||
* client (i.e. there is no separate connected client for the owner).
|
||
*/
|
||
crossAccountMoveEmails: (
|
||
emailIdsBySource: Map<string, string[]>,
|
||
destAccountId: string,
|
||
destMailboxId: string,
|
||
destJmapAccountId?: 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: IJMAPClient, emailId: string) => Promise<void>;
|
||
setEmailKeywordsLocal: (emailId: string, keywords: Record<string, boolean>) => void;
|
||
|
||
// Batch operations
|
||
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
|
||
batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise<void>;
|
||
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
|
||
batchArchive: (client: IJMAPClient) => Promise<void>;
|
||
|
||
// Spam operations
|
||
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
|
||
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: IJMAPClient) => Promise<void>;
|
||
refreshCurrentMailbox: (client: IJMAPClient) => Promise<void>;
|
||
handleNewEmailNotification: (email: Email) => void;
|
||
clearNewEmailNotification: () => void;
|
||
|
||
// Thread expansion actions
|
||
toggleThreadExpansion: (threadId: string) => void;
|
||
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
|
||
collapseAllThreads: () => void;
|
||
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
||
|
||
// Mailbox management
|
||
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>;
|
||
markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise<number>;
|
||
|
||
// Unified mailbox operations
|
||
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
|
||
loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise<void>;
|
||
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
|
||
exitUnifiedView: () => void;
|
||
|
||
fetchScheduledEmails: (client: IJMAPClient) => Promise<void>;
|
||
loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>;
|
||
cancelScheduledEmail: (client: IJMAPClient, submissionId: string, emailId?: string) => Promise<void>;
|
||
cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise<Email | null>;
|
||
rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise<SendEmailResult>;
|
||
cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>;
|
||
clearPendingUndoSend: () => void;
|
||
refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>;
|
||
setScheduledView: (isScheduledView: boolean) => void;
|
||
|
||
// Mock data for demo
|
||
loadMockData: () => void;
|
||
}
|
||
|
||
// Helper: compute the next email to select when removing one from the list
|
||
function getNextSelectedEmailAfterRemoval(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailIds: Set<string>): Email | null {
|
||
if (!state.selectedEmail || !removedEmailIds.has(state.selectedEmail.id)) {
|
||
return state.selectedEmail;
|
||
}
|
||
|
||
const idx = state.emails.findIndex(e => e.id === state.selectedEmail?.id);
|
||
if (idx === -1) return null;
|
||
|
||
for (let nextIndex = idx + 1; nextIndex < state.emails.length; nextIndex++) {
|
||
const candidate = state.emails[nextIndex];
|
||
if (!removedEmailIds.has(candidate.id)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
for (let prevIndex = idx - 1; prevIndex >= 0; prevIndex--) {
|
||
const candidate = state.emails[prevIndex];
|
||
if (!removedEmailIds.has(candidate.id)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailId: string): Email | null {
|
||
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
|
||
}
|
||
|
||
function annotateScheduledEmails(
|
||
emails: Email[],
|
||
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
||
): Email[] {
|
||
if (scheduledSubmissionByEmailId.size === 0) return emails;
|
||
return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId));
|
||
}
|
||
|
||
function annotateScheduledEmail(
|
||
email: Email,
|
||
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
||
): Email {
|
||
const scheduled = scheduledSubmissionByEmailId.get(email.id);
|
||
if (!scheduled) return email;
|
||
return {
|
||
...email,
|
||
scheduledSendAt: scheduled.sendAt,
|
||
emailSubmissionId: scheduled.submissionId,
|
||
scheduledIdentityId: scheduled.identityId,
|
||
scheduledUndoStatus: scheduled.undoStatus,
|
||
isScheduled: true,
|
||
};
|
||
}
|
||
|
||
function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean {
|
||
if (!pending) return false;
|
||
const pendingSendTime = new Date(pending.sendAt).getTime();
|
||
if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true;
|
||
const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId);
|
||
return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending';
|
||
}
|
||
|
||
/**
|
||
* When the mail view is showing a non-active account (Pro shell's
|
||
* Thunderbird-style sidebar), redirect read/write operations to that
|
||
* account's JMAP client and mailbox cache. Returns the passed-in values
|
||
* unchanged for the standard single-account flow.
|
||
*
|
||
* Compose/send still routes through the caller's client (the active
|
||
* account), since identity binding for cross-account sending is a separate
|
||
* concern.
|
||
*/
|
||
function resolveActionClient(passedClient: IJMAPClient): IJMAPClient {
|
||
const viewingId = useEmailStore.getState().viewingAccountId;
|
||
if (!viewingId) return passedClient;
|
||
const c = useAuthStore.getState().getClientForAccount(viewingId);
|
||
return c ?? passedClient;
|
||
}
|
||
|
||
function resolveActionMailboxes(): Mailbox[] {
|
||
const state = useEmailStore.getState();
|
||
if (state.viewingAccountId) {
|
||
return state.accountMailboxes[state.viewingAccountId] ?? state.mailboxes;
|
||
}
|
||
return state.mailboxes;
|
||
}
|
||
|
||
/**
|
||
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
|
||
* action (browse, load-more, search). Each entry has a JMAP client plus a
|
||
* fresh mailbox list so the helpers can resolve the role mailbox per account.
|
||
* Accounts whose mailbox fetch fails are skipped - the unified result will
|
||
* surface that in its per-account error map.
|
||
*
|
||
* When `includeGroup` is true, also emits one synthetic entry per shared
|
||
* owner account reachable through each logged-in client. The shared entries
|
||
* are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP
|
||
* requests via `originalId` + owner accountId.
|
||
*/
|
||
export async function buildUnifiedAccountClients(
|
||
opts: { includeGroup?: boolean } = {},
|
||
): Promise<UnifiedAccountClient[]> {
|
||
const { includeGroup = false } = opts;
|
||
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||
const allClients = useAuthStore.getState().getAllConnectedClients();
|
||
const built: UnifiedAccountClient[] = [];
|
||
for (const a of authAccounts) {
|
||
const c = allClients.get(a.id);
|
||
if (!c) continue;
|
||
try {
|
||
const mailboxes = includeGroup ? await c.getAllMailboxes() : await c.getMailboxes();
|
||
const ownMailboxes = includeGroup
|
||
? mailboxes.filter((m) => !m.isShared)
|
||
: mailboxes;
|
||
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, isShared: false });
|
||
|
||
if (includeGroup) {
|
||
const sharedByOwner = new Map<string, Mailbox[]>();
|
||
for (const m of mailboxes) {
|
||
if (!m.isShared || !m.accountId || m.accountId === a.id) continue;
|
||
const list = sharedByOwner.get(m.accountId) ?? [];
|
||
list.push(m);
|
||
sharedByOwner.set(m.accountId, list);
|
||
}
|
||
for (const [ownerId, ownerMailboxes] of sharedByOwner) {
|
||
const label = ownerMailboxes.find((m) => m.accountName)?.accountName || ownerId;
|
||
built.push({
|
||
accountId: ownerId,
|
||
accountLabel: label,
|
||
client: c,
|
||
mailboxes: ownerMailboxes,
|
||
isShared: true,
|
||
});
|
||
}
|
||
}
|
||
} catch {
|
||
/* skip account on mailbox fetch failure */
|
||
}
|
||
}
|
||
return built;
|
||
}
|
||
|
||
/**
|
||
* After a mailbox-list mutation (create/rename/delete/etc.), refresh the
|
||
* cache for whichever account we're operating on. Writes the result to the
|
||
* standard `mailboxes` slot for the active account, or the per-account
|
||
* cache for non-active accounts so the Pro sidebar stays in sync.
|
||
*/
|
||
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
|
||
const viewingId = useEmailStore.getState().viewingAccountId;
|
||
const client = resolveActionClient(fallbackClient);
|
||
try {
|
||
const mailboxes = await client.getMailboxes();
|
||
if (viewingId) {
|
||
useEmailStore.setState((state) => ({
|
||
accountMailboxes: { ...state.accountMailboxes, [viewingId]: mailboxes },
|
||
}));
|
||
} else {
|
||
useEmailStore.setState({ mailboxes });
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to refresh mailboxes after mutation:', error);
|
||
}
|
||
}
|
||
|
||
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
|
||
// falls back to name matching ("trash" / "deleted") so users with custom or
|
||
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
|
||
function findTrashMailbox(
|
||
mailboxes: Mailbox[],
|
||
scope: { accountId?: string; isShared?: boolean }
|
||
): Mailbox | undefined {
|
||
const matchesScope = (mb: Mailbox): boolean => {
|
||
if (scope.accountId) return mb.accountId === scope.accountId;
|
||
return !mb.isShared;
|
||
};
|
||
|
||
const byRole = mailboxes.find(mb => mb.role === 'trash' && matchesScope(mb));
|
||
if (byRole) return byRole;
|
||
|
||
return mailboxes.find(mb => {
|
||
if (!matchesScope(mb)) return false;
|
||
const lower = mb.name.toLowerCase();
|
||
return lower.includes('trash') || lower.includes('deleted');
|
||
});
|
||
}
|
||
|
||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||
emails: [],
|
||
mailboxes: [],
|
||
accountMailboxes: {},
|
||
viewingAccountId: null,
|
||
selectedEmail: null,
|
||
selectedMailbox: "",
|
||
isLoading: false,
|
||
isLoadingEmail: false,
|
||
isLoadingMore: false,
|
||
error: null,
|
||
searchQuery: "",
|
||
quota: null,
|
||
processingReadStatus: new Set(),
|
||
selectedEmailIds: new Set(),
|
||
lastSelectedEmailId: null,
|
||
hasMoreEmails: false,
|
||
totalEmails: 0,
|
||
isPushConnected: false,
|
||
lastPushUpdate: null,
|
||
newEmailNotification: null,
|
||
|
||
// Thread expansion state
|
||
expandedThreadIds: new Set(),
|
||
threadEmailsCache: new Map(),
|
||
isLoadingThread: null,
|
||
|
||
// Keyword/tag filter
|
||
selectedKeyword: null,
|
||
tagCounts: {},
|
||
|
||
// Advanced search state
|
||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||
isAdvancedSearchOpen: false,
|
||
searchAbortController: null,
|
||
externalSearchResults: [],
|
||
|
||
// Unified mailbox state
|
||
isUnifiedView: false,
|
||
unifiedRole: null,
|
||
unifiedErrors: new Map(),
|
||
unifiedCounts: [],
|
||
|
||
// Scheduled send state
|
||
scheduledEmails: [],
|
||
scheduledEmailIds: new Set(),
|
||
scheduledSubmissionByEmailId: new Map(),
|
||
scheduledTotal: 0,
|
||
scheduledHasMore: false,
|
||
scheduledNextPosition: 0,
|
||
isLoadingScheduled: false,
|
||
isScheduledView: false,
|
||
pendingUndoSend: null,
|
||
|
||
// Spam undo cache
|
||
spamUndoCache: new Map(),
|
||
|
||
setEmails: (emails) => set({ emails }),
|
||
setMailboxes: (mailboxes) => set({ mailboxes }),
|
||
setAccountMailboxes: (accountId, mailboxes) => set((state) => ({
|
||
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes },
|
||
})),
|
||
clearAccountMailboxes: () => set({ accountMailboxes: {} }),
|
||
setViewingAccount: (accountId) => set({ viewingAccountId: accountId }),
|
||
selectAccountMailbox: (accountId, mailboxId) => set({
|
||
viewingAccountId: accountId,
|
||
selectedMailbox: mailboxId,
|
||
selectedEmail: null,
|
||
selectedEmailIds: new Set(),
|
||
selectedKeyword: null,
|
||
expandedThreadIds: new Set(),
|
||
threadEmailsCache: new Map(),
|
||
isLoadingThread: null,
|
||
}),
|
||
fetchAccountMailboxes: async (client, accountId) => {
|
||
try {
|
||
const mailboxes = await client.getMailboxes();
|
||
// Re-check the cache after the await to avoid stomping a more recent
|
||
// fetch that finished while this one was in flight.
|
||
set((state) => ({
|
||
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes },
|
||
}));
|
||
} catch (error) {
|
||
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
|
||
}
|
||
},
|
||
selectEmail: (email) => {
|
||
const prev = get().selectedEmail;
|
||
set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });
|
||
if (prev && (!email || email.id !== prev.id)) {
|
||
emailHooks.onEmailClose.emitSync(prev);
|
||
}
|
||
if (email && (!prev || email.id !== prev.id)) {
|
||
emailHooks.onEmailOpen.emitSync(email);
|
||
}
|
||
},
|
||
selectKeyword: (keyword) => set({
|
||
selectedKeyword: keyword,
|
||
selectedEmail: null,
|
||
selectedEmailIds: new Set(),
|
||
expandedThreadIds: new Set(),
|
||
threadEmailsCache: new Map(),
|
||
}),
|
||
fetchTagCounts: async (client) => {
|
||
try {
|
||
const keywords = useSettingsStore.getState().emailKeywords;
|
||
if (keywords.length === 0) {
|
||
set({ tagCounts: {} });
|
||
return;
|
||
}
|
||
const tagIds = keywords.map(k => k.id);
|
||
const counts = await resolveActionClient(client).getTagCounts(tagIds);
|
||
set({ tagCounts: counts });
|
||
} catch (error) {
|
||
console.error('Failed to fetch tag counts:', error);
|
||
}
|
||
},
|
||
selectMailbox: (mailboxId) => set({
|
||
selectedMailbox: mailboxId,
|
||
selectedEmail: null,
|
||
selectedEmailIds: new Set(),
|
||
selectedKeyword: null,
|
||
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, lastSelectedEmailId: emailId });
|
||
},
|
||
|
||
selectRangeEmails: (targetEmailId) => {
|
||
const { emails, lastSelectedEmailId, selectedEmailIds } = get();
|
||
const anchorId = lastSelectedEmailId || emails[0]?.id;
|
||
if (!anchorId) return;
|
||
const anchorIndex = emails.findIndex(e => e.id === anchorId);
|
||
const targetIndex = emails.findIndex(e => e.id === targetEmailId);
|
||
if (anchorIndex === -1 || targetIndex === -1) return;
|
||
const start = Math.min(anchorIndex, targetIndex);
|
||
const end = Math.max(anchorIndex, targetIndex);
|
||
const newSelection = new Set(selectedEmailIds);
|
||
for (let i = start; i <= end; i++) {
|
||
newSelection.add(emails[i].id);
|
||
}
|
||
set({ selectedEmailIds: newSelection });
|
||
},
|
||
|
||
selectAllEmails: () => {
|
||
const { emails } = get();
|
||
const allIds = new Set(emails.map(e => e.id));
|
||
set({ selectedEmailIds: allIds });
|
||
},
|
||
|
||
clearSelection: () => {
|
||
set({ selectedEmailIds: new Set(), lastSelectedEmailId: null });
|
||
},
|
||
|
||
// JMAP operations
|
||
fetchMailboxes: async (client) => {
|
||
// Only toggle the email list's isLoading on the initial load. Background
|
||
// refreshes (after a move/archive that may have created new folders) must
|
||
// not flash the list's loading state, which hides the results-count bar
|
||
// and dims the list while folders re-fetch.
|
||
const isInitialLoad = get().mailboxes.length === 0;
|
||
if (isInitialLoad) set({ isLoading: true, error: null });
|
||
try {
|
||
const mailboxes = await client.getAllMailboxes();
|
||
|
||
// Auto-select inbox if no mailbox is selected or the current selection
|
||
// doesn't exist in the fetched list (e.g. after an account switch)
|
||
const currentSelectedMailbox = get().selectedMailbox;
|
||
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|
||
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
|
||
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
|
||
if (!selectionValid) {
|
||
// 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, ...loadingPatch });
|
||
} else {
|
||
set({ mailboxes, selectedMailbox: '', ...loadingPatch });
|
||
}
|
||
} else {
|
||
set({ mailboxes, ...loadingPatch });
|
||
}
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
|
||
...(isInitialLoad ? { isLoading: false } : {})
|
||
});
|
||
}
|
||
},
|
||
|
||
prefetchInitialData: async (client) => {
|
||
// Coalesce overlapping callers (e.g. login() and a slow home-page useEffect
|
||
// racing for the same fetch). The promise is stashed on the client so we
|
||
// don't need a separate keyed map and stale entries can't outlive the client.
|
||
const target = client as IJMAPClient & { __prefetchPromise?: Promise<void> };
|
||
if (target.__prefetchPromise) return target.__prefetchPromise;
|
||
target.__prefetchPromise = (async () => {
|
||
try {
|
||
await Promise.all([
|
||
get().fetchMailboxes(client),
|
||
get().fetchQuota(client),
|
||
]);
|
||
const { selectedMailbox } = get();
|
||
if (selectedMailbox) {
|
||
await get().fetchEmails(client, selectedMailbox);
|
||
} else {
|
||
await get().fetchEmails(client);
|
||
}
|
||
// Tag counts can finish whenever; don't block the prefetch on them.
|
||
void get().fetchTagCounts(client);
|
||
} finally {
|
||
delete target.__prefetchPromise;
|
||
}
|
||
})();
|
||
return target.__prefetchPromise;
|
||
},
|
||
|
||
fetchEmails: async (client, mailboxId) => {
|
||
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
|
||
try {
|
||
const targetMailboxId = mailboxId || get().selectedMailbox;
|
||
if (targetMailboxId === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||
set({ isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 });
|
||
await get().fetchScheduledEmails(client);
|
||
return;
|
||
}
|
||
const effectiveClient = resolveActionClient(client);
|
||
|
||
// Find the mailbox to get its accountId (for shared folder support)
|
||
const mailboxes = resolveActionMailboxes();
|
||
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;
|
||
|
||
// Build keyword filter if a tag is selected
|
||
const { selectedKeyword } = get();
|
||
const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined;
|
||
|
||
// When filtering by tag, omit the mailbox constraint so emails across
|
||
// all folders that carry the tag are returned.
|
||
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
|
||
set({
|
||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||
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, searchQuery, selectedKeyword, isUnifiedView, unifiedRole } = get();
|
||
|
||
// Don't load if already loading or no more emails
|
||
if (isLoadingMore || !hasMoreEmails) return;
|
||
|
||
// Unified view uses a different fan-out loader. When a search query or
|
||
// advanced filter is active we paginate the unified search instead of the
|
||
// unified browse, so "load more" matches what's on screen.
|
||
if (isUnifiedView && unifiedRole) {
|
||
set({ isLoadingMore: true, error: null });
|
||
try {
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||
const position = emails.length;
|
||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||
const { searchFilters } = get();
|
||
const hasFilters = !isFilterEmpty(searchFilters);
|
||
const result = hasFilters
|
||
? await advancedSearchUnifiedEmails(
|
||
built,
|
||
unifiedRole,
|
||
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
|
||
emailsPerPage,
|
||
position,
|
||
)
|
||
: searchQuery
|
||
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position)
|
||
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
|
||
const currentEmails = get().emails;
|
||
const existingIds = new Set(currentEmails.map(e => e.id));
|
||
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
|
||
set({
|
||
emails: [...currentEmails, ...newEmails],
|
||
hasMoreEmails: result.hasMore,
|
||
totalEmails: result.total,
|
||
isLoadingMore: false,
|
||
unifiedErrors: result.errors,
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load more unified emails:', error);
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to load more emails",
|
||
isLoadingMore: false,
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
set({ isLoadingMore: true, error: null });
|
||
try {
|
||
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||
set({ isLoadingMore: false });
|
||
await get().loadMoreScheduledEmails(client);
|
||
return;
|
||
}
|
||
|
||
const effectiveClient = resolveActionClient(client);
|
||
// Get emails per page from settings
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
|
||
// Capture position from current email count before the async call
|
||
const position = emails.length;
|
||
|
||
let result;
|
||
|
||
const { searchFilters } = get();
|
||
const hasFilters = !isFilterEmpty(searchFilters);
|
||
|
||
if (searchQuery || hasFilters) {
|
||
const mailboxes = resolveActionMailboxes();
|
||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||
|
||
if (hasFilters) {
|
||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position);
|
||
} else {
|
||
result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
|
||
}
|
||
} else {
|
||
// Load more from mailbox
|
||
// Find the mailbox to get its accountId (for shared folder support)
|
||
const mailboxes = resolveActionMailboxes();
|
||
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;
|
||
|
||
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
|
||
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
||
}
|
||
|
||
// Use fresh state when merging to avoid overwriting concurrent updates
|
||
// (e.g. refreshCurrentMailbox running during the load)
|
||
const currentEmails = get().emails;
|
||
|
||
// Deduplicate: the server may return overlapping results if new emails
|
||
// arrived between paginated requests and shifted positions.
|
||
const existingIds = new Set(currentEmails.map(e => e.id));
|
||
const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id));
|
||
|
||
set({
|
||
emails: [...currentEmails, ...newEmails],
|
||
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 = resolveActionMailboxes();
|
||
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
|
||
|
||
// Only pass accountId for shared mailboxes
|
||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||
|
||
const email = await resolveActionClient(client).getEmail(emailId, accountId);
|
||
|
||
if (email) {
|
||
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
|
||
set({ selectedEmail: annotatedEmail });
|
||
return annotatedEmail;
|
||
}
|
||
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 resolveActionClient(client).getQuota();
|
||
set({ quota });
|
||
} catch {
|
||
// Don't set error state as quota is optional
|
||
}
|
||
},
|
||
|
||
sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom) => {
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom);
|
||
// Refresh handled by UI layer for immediate feedback
|
||
set({
|
||
isLoading: false,
|
||
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
||
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: false }
|
||
: get().pendingUndoSend,
|
||
});
|
||
return result;
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to send email",
|
||
isLoading: false
|
||
});
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients) => {
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const mailboxes = await client.getMailboxes();
|
||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||
if (!sentMailbox) throw new Error('No sent mailbox found');
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
|
||
set({
|
||
isLoading: false,
|
||
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
||
? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: true }
|
||
: get().pendingUndoSend,
|
||
});
|
||
return result;
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to send email",
|
||
isLoading: false,
|
||
});
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
deleteEmail: async (client, emailId, forceDelete) => {
|
||
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;
|
||
const effectiveClient = resolveActionClient(client);
|
||
|
||
// Get delete action preference from settings
|
||
const deleteAction = useSettingsStore.getState().deleteAction;
|
||
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||
|
||
// Determine accountId for shared folders
|
||
const selectedMailboxId = get().selectedMailbox;
|
||
const mailboxes = resolveActionMailboxes();
|
||
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
|
||
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||
|
||
// If in junk folder and setting is enabled, permanently delete
|
||
const isInJunk = currentMailbox?.role === 'junk';
|
||
if (isInJunk && permanentlyDeleteJunk) {
|
||
forceDelete = true;
|
||
}
|
||
|
||
// If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox
|
||
if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) {
|
||
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
|
||
const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread;
|
||
|
||
if (trashMailbox) {
|
||
// Use originalId for shared mailboxes if available
|
||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||
await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead);
|
||
|
||
// After marking read in the same request, the email arrives in trash as read.
|
||
const arrivesUnread = isUnread && !alsoMarkRead;
|
||
|
||
// 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: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||
totalThreads: mailbox.totalThreads + 1,
|
||
unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||
};
|
||
}
|
||
return mailbox;
|
||
});
|
||
}
|
||
|
||
return {
|
||
emails: state.emails.filter(e => e.id !== emailId),
|
||
selectedEmail: getNextSelectedEmail(state, emailId),
|
||
mailboxes: updatedMailboxes
|
||
};
|
||
});
|
||
return;
|
||
}
|
||
// No trash folder found in this account. Surface the failure rather
|
||
// than silently destroying the email - the user asked to move it to
|
||
// trash, not to permanently delete it.
|
||
throw new Error('Trash mailbox not found - cannot move email to trash');
|
||
}
|
||
|
||
// Permanent delete
|
||
await effectiveClient.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: getNextSelectedEmail(state, emailId),
|
||
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 = resolveActionMailboxes();
|
||
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
|
||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||
|
||
await resolveActionClient(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 {
|
||
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) : [];
|
||
|
||
const { selectedMailbox } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||
|
||
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
|
||
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||
|
||
await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId);
|
||
|
||
set((state) => {
|
||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||
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
|
||
};
|
||
}
|
||
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;
|
||
});
|
||
|
||
return {
|
||
emails: state.emails.filter(e => e.id !== emailId),
|
||
selectedEmail: getNextSelectedEmail(state, emailId),
|
||
mailboxes: updatedMailboxes
|
||
};
|
||
});
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to move email"
|
||
});
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
moveEmailsToMailbox: async (client, emailIds, destinationMailboxId) => {
|
||
if (emailIds.length === 0) return;
|
||
if (emailIds.length === 1) {
|
||
await get().moveToMailbox(client, emailIds[0], destinationMailboxId);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const { emails, selectedMailbox, isUnifiedView } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
|
||
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||
const idSet = new Set(emailIds);
|
||
const affected = emails.filter(e => idSet.has(e.id));
|
||
|
||
if (isUnifiedView) {
|
||
// In unified view, emails may span accounts – group and dispatch per-account.
|
||
const byAccount = new Map<string, string[]>();
|
||
for (const e of affected) {
|
||
const acct = e.accountId || '__default__';
|
||
if (!byAccount.has(acct)) byAccount.set(acct, []);
|
||
byAccount.get(acct)!.push(e.id);
|
||
}
|
||
await Promise.all(Array.from(byAccount.entries()).map(async ([acct, ids]) => {
|
||
const acctClient = acct === '__default__' ? client : useAuthStore.getState().getClientForAccount(acct);
|
||
if (!acctClient) return;
|
||
await acctClient.batchMoveEmails(ids, jmapDestId);
|
||
}));
|
||
} else {
|
||
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||
await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId);
|
||
}
|
||
|
||
// Adjust counters and drop moved emails from the current view.
|
||
let unreadDelta = 0;
|
||
const sourceMailboxIds = new Set<string>();
|
||
for (const e of affected) {
|
||
if (!e.keywords?.$seen) unreadDelta += 1;
|
||
if (e.mailboxIds) for (const mid of Object.keys(e.mailboxIds)) sourceMailboxIds.add(mid);
|
||
}
|
||
const movedCount = affected.length;
|
||
|
||
set((state) => ({
|
||
emails: state.emails.filter(e => !idSet.has(e.id)),
|
||
selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail,
|
||
selectedEmailIds: (() => {
|
||
const next = new Set(state.selectedEmailIds);
|
||
for (const id of idSet) next.delete(id);
|
||
return next;
|
||
})(),
|
||
mailboxes: state.mailboxes.map(mb => {
|
||
if (sourceMailboxIds.has(mb.id)) {
|
||
return {
|
||
...mb,
|
||
totalEmails: Math.max(0, mb.totalEmails - movedCount),
|
||
unreadEmails: Math.max(0, mb.unreadEmails - unreadDelta),
|
||
};
|
||
}
|
||
if (mb.id === destinationMailboxId) {
|
||
return {
|
||
...mb,
|
||
totalEmails: mb.totalEmails + movedCount,
|
||
unreadEmails: mb.unreadEmails + unreadDelta,
|
||
};
|
||
}
|
||
return mb;
|
||
}),
|
||
}));
|
||
} catch (error) {
|
||
set({ error: error instanceof Error ? error.message : 'Failed to move emails' });
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => {
|
||
if (emailIdsBySource.size === 0) return;
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const destClient = useAuthStore.getState().getClientForAccount(destAccountId);
|
||
if (!destClient) {
|
||
throw new Error('Destination account is not connected');
|
||
}
|
||
|
||
const movedIds: string[] = [];
|
||
const failures: Array<{ emailId: string; error: string }> = [];
|
||
|
||
for (const [sourceAccountId, emailIds] of emailIdsBySource.entries()) {
|
||
const sourceClient = useAuthStore.getState().getClientForAccount(sourceAccountId);
|
||
if (!sourceClient) {
|
||
for (const emailId of emailIds) {
|
||
failures.push({ emailId, error: 'Source account not connected' });
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// Fan the per-email copy/import/delete pipeline out in parallel.
|
||
// JMAP has no atomic cross-account move, so we accept that a crash
|
||
// mid-flight could leave a duplicate; the delete on success keeps
|
||
// the source clean in the happy path.
|
||
const results = await Promise.allSettled(
|
||
emailIds.map(async (emailId) => {
|
||
const full = await sourceClient.getEmail(emailId);
|
||
if (!full?.blobId) {
|
||
throw new Error('Source email has no raw blob to copy');
|
||
}
|
||
const blob = await sourceClient.fetchBlob(full.blobId);
|
||
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
|
||
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
|
||
await sourceClient.deleteEmail(emailId);
|
||
return emailId;
|
||
}),
|
||
);
|
||
|
||
results.forEach((outcome, i) => {
|
||
const emailId = emailIds[i];
|
||
if (outcome.status === 'fulfilled') {
|
||
movedIds.push(emailId);
|
||
} else {
|
||
const err = outcome.reason;
|
||
failures.push({
|
||
emailId,
|
||
error: err instanceof Error ? err.message : String(err),
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Drop the moved emails from the current view and clear stale selection
|
||
// entries. Counter accuracy comes from the mailbox refresh below.
|
||
const movedSet = new Set(movedIds);
|
||
set((state) => ({
|
||
emails: state.emails.filter((e) => !movedSet.has(e.id)),
|
||
selectedEmail:
|
||
state.selectedEmail && movedSet.has(state.selectedEmail.id)
|
||
? null
|
||
: state.selectedEmail,
|
||
selectedEmailIds: (() => {
|
||
const next = new Set(state.selectedEmailIds);
|
||
for (const id of movedIds) next.delete(id);
|
||
return next;
|
||
})(),
|
||
isLoading: false,
|
||
}));
|
||
|
||
// Refresh mailbox folder lists/counters for every account we touched.
|
||
// Background-only so the move feels instant - counters will catch up.
|
||
const activeAccountId = useAuthStore.getState().activeAccountId;
|
||
const touched = new Set<string>([destAccountId, ...emailIdsBySource.keys()]);
|
||
for (const acctId of touched) {
|
||
const c = useAuthStore.getState().getClientForAccount(acctId);
|
||
if (!c) continue;
|
||
if (acctId === activeAccountId) {
|
||
void get().fetchMailboxes(c);
|
||
} else {
|
||
void get().fetchAccountMailboxes(c, acctId);
|
||
}
|
||
}
|
||
|
||
if (failures.length > 0) {
|
||
const first = failures[0];
|
||
throw new Error(
|
||
failures.length === 1
|
||
? `Failed to move email: ${first.error}`
|
||
: `Failed to move ${failures.length} email(s); first error: ${first.error}`,
|
||
);
|
||
}
|
||
} catch (error) {
|
||
set({
|
||
isLoading: false,
|
||
error:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Failed to move emails between accounts',
|
||
});
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
moveThreadToMailbox: async (client, emailId, destinationMailboxId) => {
|
||
try {
|
||
const state = get();
|
||
const email = state.emails.find(e => e.id === emailId)
|
||
?? (state.selectedEmail?.id === emailId ? state.selectedEmail : null);
|
||
|
||
if (!email?.threadId) {
|
||
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||
return;
|
||
}
|
||
|
||
const mailboxes = resolveActionMailboxes();
|
||
const effectiveClient = resolveActionClient(client);
|
||
const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox);
|
||
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
|
||
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||
|
||
const thread = await effectiveClient.getThread(email.threadId, accountId);
|
||
const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId];
|
||
|
||
if (threadEmailIds.length <= 1) {
|
||
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||
return;
|
||
}
|
||
|
||
await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
|
||
|
||
const removedEmailIds = new Set(threadEmailIds);
|
||
set((currentState) => {
|
||
const nextSelectedEmail = getNextSelectedEmailAfterRemoval(currentState, removedEmailIds);
|
||
const nextSelectedEmailIds = new Set(
|
||
Array.from(currentState.selectedEmailIds).filter(id => !removedEmailIds.has(id))
|
||
);
|
||
const nextExpandedThreadIds = new Set(currentState.expandedThreadIds);
|
||
nextExpandedThreadIds.delete(email.threadId);
|
||
const nextThreadEmailsCache = new Map(currentState.threadEmailsCache);
|
||
nextThreadEmailsCache.delete(email.threadId);
|
||
|
||
return {
|
||
emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)),
|
||
selectedEmail: nextSelectedEmail,
|
||
selectedEmailIds: nextSelectedEmailIds,
|
||
expandedThreadIds: nextExpandedThreadIds,
|
||
threadEmailsCache: nextThreadEmailsCache,
|
||
};
|
||
});
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to move email thread"
|
||
});
|
||
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 { isUnifiedView, unifiedRole } = get();
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
|
||
if (isUnifiedView && unifiedRole) {
|
||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
|
||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||
set({
|
||
emails: result.emails,
|
||
externalSearchResults: externals,
|
||
hasMoreEmails: result.hasMore,
|
||
totalEmails: result.total,
|
||
isLoading: false,
|
||
unifiedErrors: result.errors,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Get the current mailbox to scope the search
|
||
const selectedMailbox = get().selectedMailbox;
|
||
const mailboxes = resolveActionMailboxes();
|
||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||
// Use originalId for shared mailboxes
|
||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||
// Only pass accountId for shared mailboxes, not for primary account
|
||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||
|
||
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||
set({
|
||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||
externalSearchResults: externals,
|
||
hasMoreEmails: result.hasMore,
|
||
totalEmails: result.total,
|
||
isLoading: false
|
||
});
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to search emails",
|
||
isLoading: false,
|
||
emails: [],
|
||
externalSearchResults: [],
|
||
hasMoreEmails: false,
|
||
totalEmails: 0
|
||
});
|
||
}
|
||
},
|
||
|
||
advancedSearch: async (client) => {
|
||
const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
|
||
if (searchAbortController) {
|
||
searchAbortController.abort();
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
set({
|
||
isLoading: true,
|
||
error: null,
|
||
emails: [],
|
||
hasMoreEmails: false,
|
||
totalEmails: 0,
|
||
searchAbortController: controller,
|
||
});
|
||
|
||
try {
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
|
||
if (isUnifiedView && unifiedRole) {
|
||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||
const result = await advancedSearchUnifiedEmails(
|
||
built,
|
||
unifiedRole,
|
||
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
|
||
emailsPerPage,
|
||
0,
|
||
);
|
||
if (controller.signal.aborted) return;
|
||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||
set({
|
||
emails: result.emails,
|
||
externalSearchResults: externals,
|
||
hasMoreEmails: result.hasMore,
|
||
totalEmails: result.total,
|
||
isLoading: false,
|
||
searchAbortController: null,
|
||
unifiedErrors: result.errors,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||
|
||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
||
|
||
if (controller.signal.aborted) return;
|
||
|
||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||
|
||
set({
|
||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||
externalSearchResults: externals,
|
||
hasMoreEmails: result.hasMore,
|
||
totalEmails: result.total,
|
||
isLoading: false,
|
||
searchAbortController: null,
|
||
});
|
||
} catch (error) {
|
||
if (controller.signal.aborted) return;
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to search emails",
|
||
isLoading: false,
|
||
emails: [],
|
||
externalSearchResults: [],
|
||
hasMoreEmails: false,
|
||
totalEmails: 0,
|
||
searchAbortController: null,
|
||
});
|
||
}
|
||
},
|
||
|
||
setSearchFilters: (filters) => {
|
||
set((state) => ({
|
||
searchFilters: { ...state.searchFilters, ...filters },
|
||
}));
|
||
},
|
||
|
||
clearSearchFilters: () => {
|
||
set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } });
|
||
},
|
||
|
||
toggleAdvancedSearch: () => {
|
||
set((state) => ({ isAdvancedSearchOpen: !state.isAdvancedSearchOpen }));
|
||
},
|
||
|
||
toggleStar: async (client, emailId) => {
|
||
try {
|
||
const email = get().emails.find(e => e.id === emailId);
|
||
if (!email) return;
|
||
|
||
const isFlagged = email.keywords.$flagged || false;
|
||
await resolveActionClient(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;
|
||
}
|
||
},
|
||
|
||
setEmailKeywordsLocal: (emailId, keywords) => {
|
||
set((state) => ({
|
||
emails: state.emails.map(e =>
|
||
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
|
||
),
|
||
selectedEmail: state.selectedEmail?.id === emailId
|
||
? { ...state.selectedEmail, keywords: { ...keywords } }
|
||
: state.selectedEmail,
|
||
}));
|
||
},
|
||
|
||
// Batch operations
|
||
batchMarkAsRead: async (client, read) => {
|
||
const { selectedEmailIds, emails } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
if (selectedEmailIds.size === 0) return;
|
||
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const emailIdsArray = Array.from(selectedEmailIds);
|
||
|
||
if (get().isUnifiedView) {
|
||
// Group emails by accountId for cross-account operations
|
||
const emailsByAccount = new Map<string, string[]>();
|
||
for (const emailId of emailIdsArray) {
|
||
const email = emails.find(e => e.id === emailId);
|
||
const acctId = email?.accountId || '__default__';
|
||
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
|
||
emailsByAccount.get(acctId)!.push(emailId);
|
||
}
|
||
|
||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
|
||
if (!acctClient) return;
|
||
await acctClient.batchMarkAsRead(ids, read);
|
||
});
|
||
await Promise.allSettled(promises);
|
||
} else {
|
||
await resolveActionClient(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, permanent = false) => {
|
||
const { selectedEmailIds, emails, selectedMailbox } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
if (selectedEmailIds.size === 0) return;
|
||
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
const emailIdsArray = Array.from(selectedEmailIds);
|
||
|
||
// Determine if the current folder forces permanent deletion.
|
||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||
const isInTrash = currentMailbox?.role === 'trash';
|
||
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||
const isInJunk = currentMailbox?.role === 'junk';
|
||
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||
|
||
// Group emails by accountId (handles unified view and search results spanning accounts).
|
||
const emailsByAccount = new Map<string, string[]>();
|
||
for (const emailId of emailIdsArray) {
|
||
const email = emails.find(e => e.id === emailId);
|
||
const acctId = email?.accountId || '__default__';
|
||
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
|
||
emailsByAccount.get(acctId)!.push(emailId);
|
||
}
|
||
|
||
const getClient = (acctId: string) =>
|
||
acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
|
||
|
||
if (forceDestroy) {
|
||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||
const acctClient = getClient(acctId);
|
||
if (!acctClient) return;
|
||
await acctClient.batchDeleteEmails(ids);
|
||
});
|
||
await Promise.allSettled(promises);
|
||
} else {
|
||
// Move to trash per account.
|
||
const failedAccounts: string[] = [];
|
||
const movedEmailIds = new Set<string>();
|
||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||
const acctClient = getClient(acctId);
|
||
if (!acctClient) {
|
||
failedAccounts.push(acctId);
|
||
return;
|
||
}
|
||
const trashMailbox = findTrashMailbox(mailboxes, {
|
||
accountId: acctId === '__default__' ? undefined : acctId,
|
||
});
|
||
if (!trashMailbox) {
|
||
// No trash for this account: skip rather than silently destroying.
|
||
// The user asked to move to trash, not permanently delete.
|
||
failedAccounts.push(acctId);
|
||
return;
|
||
}
|
||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead);
|
||
ids.forEach(id => movedEmailIds.add(id));
|
||
});
|
||
await Promise.allSettled(promises);
|
||
|
||
if (failedAccounts.length > 0 && movedEmailIds.size === 0) {
|
||
// Nothing moved - bail out so the UI doesn't drop the emails from view.
|
||
throw new Error('Trash mailbox not found - cannot move emails to trash');
|
||
}
|
||
|
||
// Only remove successfully moved emails from local state.
|
||
if (movedEmailIds.size < emailIdsArray.length) {
|
||
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
|
||
const remainingEmails = emails.filter(e => !movedEmailIds.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,
|
||
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
|
||
if (get().isUnifiedView) {
|
||
// Group emails by accountId for cross-account operations
|
||
const emailsByAccount = new Map<string, string[]>();
|
||
for (const emailId of emailIdsArray) {
|
||
const email = emails.find(e => e.id === emailId);
|
||
const acctId = email?.accountId || '__default__';
|
||
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
|
||
emailsByAccount.get(acctId)!.push(emailId);
|
||
}
|
||
|
||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
|
||
if (!acctClient) return;
|
||
await acctClient.batchMoveEmails(ids, toMailboxId);
|
||
});
|
||
await Promise.allSettled(promises);
|
||
} else {
|
||
await resolveActionClient(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 (honors active search/filters)
|
||
if (!get().isUnifiedView) {
|
||
await get().refreshCurrentMailbox(client);
|
||
}
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to move emails",
|
||
isLoading: false
|
||
});
|
||
}
|
||
},
|
||
|
||
batchArchive: async (client) => {
|
||
const { selectedEmailIds, emails } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
if (selectedEmailIds.size === 0) return;
|
||
|
||
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
|
||
if (!archiveMailbox) return;
|
||
|
||
const mode = useSettingsStore.getState().archiveMode;
|
||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||
|
||
const selected = emails.filter(e => selectedEmailIds.has(e.id));
|
||
if (selected.length === 0) return;
|
||
|
||
set({ isLoading: true, error: null });
|
||
try {
|
||
await resolveActionClient(client).batchArchiveEmails(
|
||
selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
|
||
archiveId,
|
||
mode,
|
||
mailboxes,
|
||
archiveMailbox.accountId,
|
||
);
|
||
|
||
const remaining = emails.filter(e => !selectedEmailIds.has(e.id));
|
||
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
|
||
|
||
// Refresh the active or viewed account's mailbox cache after the
|
||
// archive (a year/month archive can create new sub-folders).
|
||
await refreshMailboxesForViewingAccount(client);
|
||
await get().refreshCurrentMailbox(client);
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : 'Failed to archive emails',
|
||
isLoading: false,
|
||
});
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
// Spam operations
|
||
markAsSpam: async (client, emailId) => {
|
||
const { selectedMailbox, emails } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
const email = emails.find(e => e.id === emailId);
|
||
if (!email) return;
|
||
|
||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||
if (!currentMailbox) return;
|
||
|
||
get().spamUndoCache.set(emailId, {
|
||
emailId,
|
||
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
|
||
accountId: currentMailbox.accountId,
|
||
});
|
||
|
||
try {
|
||
const isUnread = !email.keywords?.$seen;
|
||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
|
||
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead);
|
||
|
||
set(state => ({
|
||
emails: state.emails.filter(e => e.id !== emailId),
|
||
selectedEmail: getNextSelectedEmail(state, emailId),
|
||
}));
|
||
} catch (error) {
|
||
console.error('Failed to mark as spam:', error);
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
undoSpam: async (client, emailId) => {
|
||
const { selectedMailbox } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
|
||
// Try cache first (preserves exact original mailbox for toast undo)
|
||
const cachedData = get().spamUndoCache.get(emailId);
|
||
|
||
let targetMailboxId: string;
|
||
let accountId: string | undefined;
|
||
|
||
if (cachedData) {
|
||
// Use cached original mailbox (more accurate for immediate undo)
|
||
targetMailboxId = cachedData.originalMailboxId;
|
||
accountId = cachedData.accountId;
|
||
get().spamUndoCache.delete(emailId);
|
||
} else {
|
||
// Fall back to finding Inbox (generic "not spam" button/menu)
|
||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||
accountId = currentMailbox?.accountId;
|
||
|
||
// Find inbox in same account
|
||
const inboxMailbox = mailboxes.find(m =>
|
||
m.role === 'inbox' &&
|
||
(accountId ? m.accountId === accountId : !m.accountId)
|
||
);
|
||
|
||
if (!inboxMailbox) {
|
||
throw new Error('Inbox not found');
|
||
}
|
||
|
||
targetMailboxId = inboxMailbox.originalId || inboxMailbox.id;
|
||
}
|
||
|
||
try {
|
||
await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId);
|
||
await get().fetchEmails(client, selectedMailbox);
|
||
} catch (error) {
|
||
console.error('Failed to restore email:', error);
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
batchMarkAsSpam: async (client, emailIds) => {
|
||
const { selectedMailbox, emails } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
const effectiveClient = resolveActionClient(client);
|
||
|
||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||
if (!currentMailbox) return;
|
||
|
||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||
|
||
try {
|
||
for (const emailId of emailIds) {
|
||
const email = emails.find(e => e.id === emailId);
|
||
const markRead = alsoMarkRead && !!email && !email.keywords?.$seen;
|
||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead);
|
||
}
|
||
|
||
set(state => ({
|
||
emails: state.emails.filter(e => !emailIds.includes(e.id)),
|
||
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
|
||
selectedEmailIds: new Set(),
|
||
}));
|
||
} catch (error) {
|
||
console.error('Failed to batch mark as spam:', error);
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => {
|
||
const { selectedMailbox } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
const effectiveClient = resolveActionClient(client);
|
||
|
||
// Find inbox (batch operations don't preserve original mailboxes)
|
||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||
const accountId = currentMailbox?.accountId;
|
||
|
||
const inboxMailbox = mailboxes.find(m =>
|
||
m.role === 'inbox' &&
|
||
(accountId ? m.accountId === accountId : !m.accountId)
|
||
);
|
||
|
||
if (!inboxMailbox) {
|
||
throw new Error('Inbox not found');
|
||
}
|
||
|
||
try {
|
||
for (const emailId of emailIds) {
|
||
await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
|
||
}
|
||
|
||
set(state => ({
|
||
emails: state.emails.filter(e => !emailIds.includes(e.id)),
|
||
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
|
||
selectedEmailIds: new Set(),
|
||
}));
|
||
} catch (error) {
|
||
console.error('Failed to batch restore emails:', error);
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
// 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);
|
||
get().fetchTagCounts(client);
|
||
}
|
||
|
||
if (accountChanges.EmailSubmission) {
|
||
await get().refreshScheduledMetadata(client);
|
||
if (get().isScheduledView) {
|
||
await get().fetchScheduledEmails(client);
|
||
}
|
||
}
|
||
|
||
// Handle Mailbox state changes - refresh mailbox list
|
||
if (accountChanges.Mailbox) {
|
||
await get().fetchMailboxes(client);
|
||
}
|
||
|
||
// Handle Calendar/CalendarEvent state changes - refresh calendar data
|
||
if (accountChanges.Calendar || accountChanges.CalendarEvent) {
|
||
const calendarStore = useCalendarStore.getState();
|
||
if (calendarStore.supportsCalendar) {
|
||
calendarStore.fetchCalendars(client);
|
||
const { dateRange, selectedCalendarIds } = calendarStore;
|
||
if (dateRange && selectedCalendarIds.length > 0) {
|
||
calendarStore.fetchEvents(client, dateRange.start, dateRange.end);
|
||
}
|
||
// Refresh tasks when calendar events change (e.g. task created via CalDAV)
|
||
const { useTaskStore } = await import('./task-store');
|
||
const taskStore = useTaskStore.getState();
|
||
if (taskStore.tasks.length > 0 || calendarStore.viewMode === 'tasks') {
|
||
taskStore.fetchTasks(client);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle SieveScript state changes - refresh filter rules
|
||
if (accountChanges.SieveScript) {
|
||
const { useFilterStore } = await import('./filter-store');
|
||
const filterStore = useFilterStore.getState();
|
||
if (filterStore.isSupported) {
|
||
filterStore.fetchFilters(client).catch((err) => {
|
||
console.error('Failed to refresh filters:', err);
|
||
});
|
||
}
|
||
}
|
||
} 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;
|
||
|
||
if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
||
await get().fetchScheduledEmails(client);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Fetch emails for the current mailbox without clearing the list first
|
||
// This provides a smoother update experience
|
||
const mailboxes = resolveActionMailboxes();
|
||
const effectiveClient = resolveActionClient(client);
|
||
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;
|
||
|
||
// Respect active search filters / query so that a push-triggered refresh
|
||
// does not silently replace a filtered list with an unfiltered one.
|
||
const { searchQuery, searchFilters } = get();
|
||
const hasFilters = !isFilterEmpty(searchFilters);
|
||
|
||
let result;
|
||
if (hasFilters || searchQuery) {
|
||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
||
} else {
|
||
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
|
||
}
|
||
|
||
const currentEmails = get().emails;
|
||
const previousTotal = get().totalEmails;
|
||
|
||
// Only notify for genuinely new incoming mail in the Inbox.
|
||
// Without these guards the toast/sound also fires when sending,
|
||
// saving drafts, or moving/deleting the top message in any mailbox,
|
||
// because all of those change the first-email id of the current view.
|
||
const newFirst = result.emails[0];
|
||
if (
|
||
newFirst &&
|
||
mailbox?.role === 'inbox' &&
|
||
!currentEmails.some(e => e.id === newFirst.id)
|
||
) {
|
||
get().handleNewEmailNotification(newFirst);
|
||
}
|
||
|
||
// Merge the refreshed first page with the existing loaded emails.
|
||
// This avoids discarding already-loaded pages which would cause the
|
||
// virtual list to shrink and then rapidly re-load (scroll bounce).
|
||
const refreshedEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId);
|
||
|
||
// Build the merged list: start with the fresh first page, then append
|
||
// existing emails beyond that page (if any), skipping duplicates. Do not
|
||
// append the whole previous list: drafts are saved as destroy+create, so
|
||
// the old draft can disappear from the refreshed first page and must not
|
||
// be reintroduced from stale local state.
|
||
const merged: Email[] = [...refreshedEmails];
|
||
const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id));
|
||
const insertedCount = Math.max((result.total || 0) - previousTotal, 0);
|
||
const appendFromIndex = Math.max(refreshedEmails.length - insertedCount, 0);
|
||
|
||
for (const email of currentEmails.slice(appendFromIndex)) {
|
||
if (!mergedIds.has(email.id)) {
|
||
merged.push(email);
|
||
mergedIds.add(email.id);
|
||
}
|
||
}
|
||
|
||
// Check if anything actually changed to avoid unnecessary re-renders
|
||
const hasChanged =
|
||
currentEmails.length !== merged.length ||
|
||
merged.some((email, i) => {
|
||
const curr = currentEmails[i];
|
||
if (!curr) return true;
|
||
return (
|
||
curr.id !== email.id ||
|
||
curr.threadId !== email.threadId ||
|
||
JSON.stringify(curr.keywords) !== JSON.stringify(email.keywords)
|
||
);
|
||
});
|
||
|
||
if (hasChanged) {
|
||
// hasMore should reflect whether there are still more emails beyond
|
||
// what we have loaded, using the fresh total from the server.
|
||
const hasMore = merged.length < (result.total || 0);
|
||
set({
|
||
emails: merged,
|
||
hasMoreEmails: 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 } = get();
|
||
const mailboxes = resolveActionMailboxes();
|
||
|
||
// 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 resolveActionClient(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 });
|
||
},
|
||
|
||
// Mailbox management
|
||
createMailbox: async (client, name, parentId) => {
|
||
try {
|
||
await resolveActionClient(client).createMailbox(name, parentId);
|
||
if (get().viewingAccountId) {
|
||
await refreshMailboxesForViewingAccount(client);
|
||
} else {
|
||
await get().fetchMailboxes(client);
|
||
}
|
||
} catch (error) {
|
||
set({ error: error instanceof Error ? error.message : 'Failed to create folder' });
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
renameMailbox: async (client, mailboxId, name) => {
|
||
try {
|
||
await resolveActionClient(client).updateMailbox(mailboxId, { name });
|
||
const viewingId = get().viewingAccountId;
|
||
if (viewingId) {
|
||
set((state) => ({
|
||
accountMailboxes: {
|
||
...state.accountMailboxes,
|
||
[viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb =>
|
||
mb.id === mailboxId ? { ...mb, name } : mb
|
||
),
|
||
},
|
||
}));
|
||
} else {
|
||
set({
|
||
mailboxes: get().mailboxes.map(mb =>
|
||
mb.id === mailboxId ? { ...mb, name } : mb
|
||
),
|
||
});
|
||
}
|
||
} catch (error) {
|
||
set({ error: error instanceof Error ? error.message : 'Failed to rename folder' });
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
deleteMailbox: async (client, mailboxId) => {
|
||
try {
|
||
await resolveActionClient(client).deleteMailbox(mailboxId);
|
||
const { selectedMailbox, viewingAccountId: viewingId } = get();
|
||
if (viewingId) {
|
||
const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId);
|
||
const patch: Partial<EmailStore> = {
|
||
accountMailboxes: { ...get().accountMailboxes, [viewingId]: updatedList },
|
||
};
|
||
if (selectedMailbox === mailboxId) {
|
||
const inbox = updatedList.find(mb => mb.role === 'inbox' && !mb.isShared);
|
||
if (inbox) patch.selectedMailbox = inbox.id;
|
||
}
|
||
set(patch);
|
||
} else {
|
||
const newMailboxes = get().mailboxes.filter(mb => mb.id !== mailboxId);
|
||
const updates: Partial<EmailStore> = { mailboxes: newMailboxes };
|
||
if (selectedMailbox === mailboxId) {
|
||
const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
|
||
if (inbox) updates.selectedMailbox = inbox.id;
|
||
}
|
||
set(updates);
|
||
}
|
||
} catch (error) {
|
||
set({ error: error instanceof Error ? error.message : 'Failed to delete folder' });
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
setMailboxRole: async (client, mailboxId, role) => {
|
||
try {
|
||
const effectiveClient = resolveActionClient(client);
|
||
// If assigning a role, first clear that role from ALL other mailboxes that have it
|
||
if (role) {
|
||
const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId);
|
||
for (const existing of existingMailboxes) {
|
||
await effectiveClient.updateMailbox(existing.id, { role: null });
|
||
}
|
||
}
|
||
await effectiveClient.updateMailbox(mailboxId, { role });
|
||
if (get().viewingAccountId) {
|
||
await refreshMailboxesForViewingAccount(client);
|
||
} else {
|
||
await get().fetchMailboxes(client);
|
||
}
|
||
} catch (error) {
|
||
set({ error: error instanceof Error ? error.message : 'Failed to update folder role' });
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
emptyMailbox: async (client, mailboxId) => {
|
||
try {
|
||
set({ isLoading: true, error: null });
|
||
await resolveActionClient(client).emptyMailbox(mailboxId);
|
||
|
||
// Clear emails from local state if we're viewing this mailbox
|
||
const currentMailbox = get().selectedMailbox;
|
||
if (currentMailbox === mailboxId) {
|
||
set({ emails: [], selectedEmail: null });
|
||
}
|
||
|
||
const viewingId = get().viewingAccountId;
|
||
if (viewingId) {
|
||
set((state) => ({
|
||
accountMailboxes: {
|
||
...state.accountMailboxes,
|
||
[viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb =>
|
||
mb.id === mailboxId
|
||
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
|
||
: mb
|
||
),
|
||
},
|
||
}));
|
||
} else {
|
||
set({
|
||
mailboxes: get().mailboxes.map(mb =>
|
||
mb.id === mailboxId
|
||
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
|
||
: mb
|
||
),
|
||
});
|
||
}
|
||
set({ isLoading: false });
|
||
} catch (error) {
|
||
set({
|
||
error: error instanceof Error ? error.message : 'Failed to empty folder',
|
||
isLoading: false,
|
||
});
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
markMailboxAsRead: async (client, mailboxId) => {
|
||
try {
|
||
const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId);
|
||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||
const jmapMailboxId = mailbox?.originalId || mailboxId;
|
||
|
||
const count = await resolveActionClient(client).markMailboxAsRead(jmapMailboxId, accountId);
|
||
|
||
// Update local state: mark all emails currently visible in this mailbox as read,
|
||
// and zero-out the mailbox unread counter.
|
||
set((state) => ({
|
||
emails: state.emails.map(e =>
|
||
e.mailboxIds && e.mailboxIds[mailboxId]
|
||
? { ...e, keywords: { ...e.keywords, $seen: true } }
|
||
: e
|
||
),
|
||
selectedEmail: state.selectedEmail && state.selectedEmail.mailboxIds?.[mailboxId]
|
||
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
|
||
: state.selectedEmail,
|
||
mailboxes: state.mailboxes.map(mb =>
|
||
mb.id === mailboxId
|
||
? { ...mb, unreadEmails: 0, unreadThreads: 0 }
|
||
: mb
|
||
),
|
||
}));
|
||
|
||
return count;
|
||
} catch (error) {
|
||
set({ error: error instanceof Error ? error.message : 'Failed to mark folder as read' });
|
||
throw error;
|
||
}
|
||
},
|
||
|
||
// Unified mailbox operations
|
||
fetchUnifiedEmails: async (accounts, role) => {
|
||
set({
|
||
isLoading: true,
|
||
error: null,
|
||
isUnifiedView: true,
|
||
unifiedRole: role,
|
||
selectedKeyword: null,
|
||
});
|
||
try {
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0);
|
||
set({
|
||
emails: result.emails,
|
||
hasMoreEmails: result.hasMore,
|
||
totalEmails: result.total,
|
||
isLoading: false,
|
||
unifiedErrors: result.errors,
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to fetch unified emails:', error);
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to fetch unified emails",
|
||
isLoading: false,
|
||
emails: [],
|
||
hasMoreEmails: false,
|
||
totalEmails: 0,
|
||
});
|
||
}
|
||
},
|
||
|
||
loadMoreUnifiedEmails: async (accounts) => {
|
||
const { isLoadingMore, hasMoreEmails, emails, unifiedRole } = get();
|
||
if (isLoadingMore || !hasMoreEmails || !unifiedRole) return;
|
||
|
||
set({ isLoadingMore: true, error: null });
|
||
try {
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
const position = emails.length;
|
||
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position);
|
||
|
||
const currentEmails = get().emails;
|
||
const existingIds = new Set(currentEmails.map(e => e.id));
|
||
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
|
||
|
||
set({
|
||
emails: [...currentEmails, ...newEmails],
|
||
hasMoreEmails: result.hasMore,
|
||
totalEmails: result.total,
|
||
isLoadingMore: false,
|
||
unifiedErrors: result.errors,
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to load more unified emails:', error);
|
||
set({
|
||
error: error instanceof Error ? error.message : "Failed to load more unified emails",
|
||
isLoadingMore: false,
|
||
});
|
||
}
|
||
},
|
||
|
||
refreshUnifiedCounts: async (accounts) => {
|
||
try {
|
||
const counts = fetchUnifiedMailboxCounts(accounts);
|
||
set({ unifiedCounts: counts });
|
||
} catch (error) {
|
||
console.error('Failed to refresh unified counts:', error);
|
||
}
|
||
},
|
||
|
||
exitUnifiedView: () => {
|
||
set({
|
||
isUnifiedView: false,
|
||
unifiedRole: null,
|
||
unifiedErrors: new Map(),
|
||
});
|
||
},
|
||
|
||
setScheduledView: (isScheduledView) => set(state => {
|
||
const leavingScheduled = !isScheduledView && state.selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID;
|
||
return {
|
||
isScheduledView,
|
||
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
|
||
selectedEmail: leavingScheduled ? null : state.selectedEmail,
|
||
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
|
||
};
|
||
}),
|
||
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
|
||
|
||
fetchScheduledEmails: async (client) => {
|
||
set({ isLoadingScheduled: true, error: null });
|
||
try {
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
const result = await client.getScheduledEmails(emailsPerPage, 0);
|
||
const scheduledEmailIds = new Set(result.emails.map(email => email.id));
|
||
const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, {
|
||
submissionId: email.emailSubmissionId,
|
||
sendAt: email.scheduledSendAt,
|
||
identityId: email.scheduledIdentityId,
|
||
undoStatus: email.scheduledUndoStatus,
|
||
}]));
|
||
const pendingUndoSend = get().pendingUndoSend;
|
||
set({
|
||
scheduledEmails: result.emails,
|
||
scheduledEmailIds,
|
||
scheduledSubmissionByEmailId,
|
||
scheduledTotal: result.total,
|
||
scheduledHasMore: result.hasMore,
|
||
scheduledNextPosition: result.nextPosition,
|
||
isLoadingScheduled: false,
|
||
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, result.emails) ? null : pendingUndoSend,
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to fetch scheduled emails:', error);
|
||
set({
|
||
error: error instanceof Error ? error.message : 'Failed to fetch scheduled emails',
|
||
scheduledEmails: [],
|
||
scheduledEmailIds: new Set(),
|
||
scheduledSubmissionByEmailId: new Map(),
|
||
scheduledTotal: 0,
|
||
scheduledHasMore: false,
|
||
scheduledNextPosition: 0,
|
||
isLoadingScheduled: false,
|
||
});
|
||
}
|
||
},
|
||
|
||
loadMoreScheduledEmails: async (client) => {
|
||
const { isLoadingScheduled, scheduledHasMore, scheduledEmails, scheduledNextPosition } = get();
|
||
if (isLoadingScheduled || !scheduledHasMore) return;
|
||
set({ isLoadingScheduled: true, error: null });
|
||
try {
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition);
|
||
const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))];
|
||
const pendingUndoSend = get().pendingUndoSend;
|
||
set({
|
||
scheduledEmails: merged,
|
||
scheduledEmailIds: new Set(merged.map(email => email.id)),
|
||
scheduledSubmissionByEmailId: new Map(merged.map(email => [email.id, {
|
||
submissionId: email.emailSubmissionId,
|
||
sendAt: email.scheduledSendAt,
|
||
identityId: email.scheduledIdentityId,
|
||
undoStatus: email.scheduledUndoStatus,
|
||
}])),
|
||
scheduledTotal: result.total,
|
||
scheduledHasMore: result.hasMore,
|
||
scheduledNextPosition: result.nextPosition,
|
||
isLoadingScheduled: false,
|
||
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, merged) ? null : pendingUndoSend,
|
||
});
|
||
} catch (error) {
|
||
set({ error: error instanceof Error ? error.message : 'Failed to load scheduled emails', isLoadingScheduled: false });
|
||
}
|
||
},
|
||
|
||
refreshScheduledMetadata: async (client) => {
|
||
try {
|
||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||
const allEmails: ScheduledEmail[] = [];
|
||
let position = 0;
|
||
let hasMore = true;
|
||
let total = 0;
|
||
while (hasMore) {
|
||
const page = await client.getScheduledEmails(emailsPerPage, position);
|
||
allEmails.push(...page.emails.filter(email => !allEmails.some(existing => existing.id === email.id)));
|
||
total = page.total;
|
||
hasMore = page.hasMore && page.nextPosition > position;
|
||
position = page.nextPosition;
|
||
}
|
||
const pendingUndoSend = get().pendingUndoSend;
|
||
set({
|
||
scheduledEmails: get().isScheduledView ? allEmails : get().scheduledEmails,
|
||
scheduledEmailIds: new Set(allEmails.map(email => email.id)),
|
||
scheduledSubmissionByEmailId: new Map(allEmails.map(email => [email.id, {
|
||
submissionId: email.emailSubmissionId,
|
||
sendAt: email.scheduledSendAt,
|
||
identityId: email.scheduledIdentityId,
|
||
undoStatus: email.scheduledUndoStatus,
|
||
}])),
|
||
scheduledTotal: total,
|
||
scheduledHasMore: false,
|
||
scheduledNextPosition: position,
|
||
pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, allEmails) ? null : pendingUndoSend,
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to refresh scheduled metadata:', error);
|
||
}
|
||
},
|
||
|
||
cancelScheduledEmail: async (client, submissionId, emailId) => {
|
||
await client.cancelEmailSubmission(submissionId);
|
||
if (emailId) {
|
||
await client.deleteEmail(emailId);
|
||
set(state => ({
|
||
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
|
||
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== emailId)),
|
||
}));
|
||
}
|
||
if (get().pendingUndoSend?.submissionId === submissionId) {
|
||
set({ pendingUndoSend: null });
|
||
}
|
||
await get().fetchScheduledEmails(client);
|
||
},
|
||
|
||
cancelScheduledEmailForEdit: async (client, email) => {
|
||
const submissionId = email.emailSubmissionId;
|
||
if (!submissionId) return null;
|
||
await client.cancelEmailSubmission(submissionId);
|
||
if (get().pendingUndoSend?.submissionId === submissionId) {
|
||
set({ pendingUndoSend: null });
|
||
}
|
||
if (email.isSmimeScheduled) {
|
||
await client.deleteEmail(email.id);
|
||
set(state => ({
|
||
selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail,
|
||
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)),
|
||
}));
|
||
await get().fetchScheduledEmails(client);
|
||
return null;
|
||
}
|
||
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||
if (draftsMailbox) {
|
||
await client.restoreEmailToDraft(email.id, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
|
||
}
|
||
await get().fetchScheduledEmails(client);
|
||
const restored = await client.getEmail(email.id);
|
||
return restored;
|
||
},
|
||
|
||
rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => {
|
||
let result: SendEmailResult | undefined;
|
||
try {
|
||
result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil);
|
||
const pendingUndoSend = get().pendingUndoSend;
|
||
if (pendingUndoSend?.submissionId === submissionId) {
|
||
set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } });
|
||
}
|
||
return result;
|
||
} finally {
|
||
await get().fetchScheduledEmails(client);
|
||
if (result && get().selectedEmail?.id === emailId) {
|
||
const refreshed = get().scheduledEmails.find(email => email.id === emailId);
|
||
set(state => ({
|
||
selectedEmail: refreshed || (state.selectedEmail ? {
|
||
...state.selectedEmail,
|
||
emailSubmissionId: result?.emailSubmissionId || submissionId,
|
||
scheduledSendAt: result?.sendAt || delayedUntil,
|
||
scheduledIdentityId: identityId,
|
||
scheduledUndoStatus: 'pending' as const,
|
||
isScheduled: true,
|
||
} : state.selectedEmail),
|
||
}));
|
||
}
|
||
}
|
||
},
|
||
|
||
cancelUndoSend: async (client, pending) => {
|
||
await client.cancelEmailSubmission(pending.submissionId);
|
||
if (pending.emailId && pending.isSmime) {
|
||
await client.deleteEmail(pending.emailId);
|
||
set(state => ({
|
||
selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail,
|
||
selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)),
|
||
}));
|
||
} else if (pending.emailId) {
|
||
const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes();
|
||
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
|
||
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
|
||
if (draftsMailbox) {
|
||
await client.restoreEmailToDraft(pending.emailId, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id);
|
||
}
|
||
}
|
||
await get().refreshScheduledMetadata(client);
|
||
set({ pendingUndoSend: null });
|
||
return pending.emailId && !pending.isSmime ? client.getEmail(pending.emailId) : null;
|
||
},
|
||
|
||
loadMockData: () => {
|
||
const mockEmails: Email[] = [
|
||
{
|
||
id: "1",
|
||
threadId: "thread-1",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: false },
|
||
size: 1024,
|
||
receivedAt: new Date().toISOString(),
|
||
from: [{ name: "GitHub", email: "notifications@github.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "[bulwark-webmail] New pull request #42: Add OAuth2 module",
|
||
preview: "dependabot[bot] opened a pull request in bulwarkmail/webmail. This PR adds a comprehensive authentication module with OAuth2 PKCE support...",
|
||
hasAttachment: false,
|
||
},
|
||
{
|
||
id: "2",
|
||
threadId: "thread-2",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: true, $flagged: true },
|
||
size: 512,
|
||
receivedAt: new Date(Date.now() - 3600000).toISOString(),
|
||
from: [{ name: "Emily Chen", email: "emily.chen@gmail.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "Re: Dashboard Redesign v2 - feedback",
|
||
preview: "Hey! I just pushed the updated mockups to Figma. I incorporated all the feedback from last week's meeting. Let me know what you think about the new nav...",
|
||
hasAttachment: true,
|
||
},
|
||
{
|
||
id: "3",
|
||
threadId: "thread-3",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: false },
|
||
size: 2048,
|
||
receivedAt: new Date(Date.now() - 7200000).toISOString(),
|
||
from: [{ name: "Slack", email: "notifications@slack.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "3 new messages in #engineering",
|
||
preview: "Marcus: Hey team, the CI pipeline is green again. Sarah: Great, merging the feature branch now. Alex: Let's do a quick sync at 3 PM...",
|
||
hasAttachment: false,
|
||
},
|
||
{
|
||
id: "4",
|
||
threadId: "thread-4",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: true },
|
||
size: 768,
|
||
receivedAt: new Date(Date.now() - 14400000).toISOString(),
|
||
from: [{ name: "Marcus Rivera", email: "marcus.rivera@outlook.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "Quick question about the API rate limits",
|
||
preview: "Hey, I was looking at the JMAP spec and I'm not sure how we should handle rate limiting on the server side. Do you have any thoughts on...",
|
||
hasAttachment: false,
|
||
},
|
||
{
|
||
id: "5",
|
||
threadId: "thread-5",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: true },
|
||
size: 1536,
|
||
receivedAt: new Date(Date.now() - 86400000).toISOString(),
|
||
from: [{ name: "Stripe", email: "receipts@stripe.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "Your invoice from Acme Corp is ready",
|
||
preview: "Invoice #INV-2026-0312 for $49.00 has been paid. Thank you for your payment. View your receipt and download your invoice...",
|
||
hasAttachment: true,
|
||
},
|
||
{
|
||
id: "6",
|
||
threadId: "thread-6",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: false },
|
||
size: 3072,
|
||
receivedAt: new Date(Date.now() - 108000000).toISOString(),
|
||
from: [{ name: "Sarah Kim", email: "sarah.kim@proton.me" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "Conference talk proposal - need your review",
|
||
preview: "I'm submitting a talk to ReactConf about our email client architecture. Could you take a look at my abstract before the deadline on Friday?...",
|
||
hasAttachment: true,
|
||
},
|
||
{
|
||
id: "7",
|
||
threadId: "thread-7",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: true, $flagged: true },
|
||
size: 4096,
|
||
receivedAt: new Date(Date.now() - 172800000).toISOString(),
|
||
from: [{ name: "Vercel", email: "notifications@vercel.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "Deployment successful: bulwark-webmail \u2192 Production",
|
||
preview: "Your project bulwark-webmail has been deployed to production. Build completed in 47s. All checks passed. Preview: https://bulwark-webmail.vercel.app...",
|
||
hasAttachment: false,
|
||
},
|
||
{
|
||
id: "8",
|
||
threadId: "thread-8",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: true },
|
||
size: 2560,
|
||
receivedAt: new Date(Date.now() - 259200000).toISOString(),
|
||
from: [{ name: "Alex Petrov", email: "alex.petrov@fastmail.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "Meeting notes from yesterday's standup",
|
||
preview: "Here are the action items from yesterday. 1) Finish the drag-and-drop implementation by Wednesday. 2) Review the accessibility audit results...",
|
||
hasAttachment: false,
|
||
},
|
||
{
|
||
id: "9",
|
||
threadId: "thread-9",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: false },
|
||
size: 1280,
|
||
receivedAt: new Date(Date.now() - 345600000).toISOString(),
|
||
from: [{ name: "Linear", email: "notifications@linear.app" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "ENG-384: Implement email threading view \u2014 moved to In Progress",
|
||
preview: "Alice Johnson moved ENG-384 to In Progress. This issue covers implementing the conversation thread view for the email client...",
|
||
hasAttachment: false,
|
||
},
|
||
{
|
||
id: "10",
|
||
threadId: "thread-10",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: true },
|
||
size: 896,
|
||
receivedAt: new Date(Date.now() - 432000000).toISOString(),
|
||
from: [{ name: "Priya Sharma", email: "priya.sharma@icloud.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "Re: Onboarding docs for new contributors",
|
||
preview: "Thanks for putting this together! I added a section on setting up the dev environment. Also linked the architecture diagram from our wiki...",
|
||
hasAttachment: false,
|
||
},
|
||
{
|
||
id: "11",
|
||
threadId: "thread-11",
|
||
mailboxIds: { inbox: true },
|
||
keywords: { $seen: true },
|
||
size: 5120,
|
||
receivedAt: new Date(Date.now() - 518400000).toISOString(),
|
||
from: [{ name: "LaunchWeekly", email: "newsletter@launchweekly.com" }],
|
||
to: [{ email: "you@example.com" }],
|
||
subject: "\uD83D\uDE80 This week in tech: AI agents, new frameworks, and more",
|
||
preview: "Happy Monday! Here's your weekly roundup of the most interesting launches, open-source projects, and developer tools you might have missed...",
|
||
hasAttachment: false,
|
||
},
|
||
];
|
||
|
||
const mockMailboxes: Mailbox[] = [
|
||
{
|
||
id: "inbox",
|
||
name: "Inbox",
|
||
role: "inbox",
|
||
sortOrder: 1,
|
||
totalEmails: 11,
|
||
unreadEmails: 4,
|
||
totalThreads: 11,
|
||
unreadThreads: 4,
|
||
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,
|
||
});
|
||
},
|
||
}));
|