mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
8060 lines
368 KiB
TypeScript
8060 lines
368 KiB
TypeScript
import { ArchiveEmailsError } from "@/lib/jmap/archive-error";
|
|
import { RescheduleRestoredError } from "@/lib/jmap/reschedule-error";
|
|
import { create } from "zustand";
|
|
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
|
|
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
|
import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface";
|
|
import { jmapErrorFailedIds } from "@/lib/jmap/jmap-error";
|
|
import type { MessagePriority } from "@/lib/message-priority";
|
|
import { withSearchSnippets, type DeferredSnippetBatch } from "@/lib/search-snippet";
|
|
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
|
|
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
|
|
import { isMailboxMuted } from "@/lib/notification-mutes";
|
|
import { useCalendarStore } from "@/stores/calendar-store";
|
|
import { orderKeywords, placeHeldRows, sanitizeSortLevels, type HeldKeywords, type SortLevel } from "@/lib/message-list-order";
|
|
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty, textFilter } from "@/lib/jmap/search-utils";
|
|
import { emailHooks } from "@/lib/plugin-hooks";
|
|
import { scheduleAutoMarkAsRead } from "@/lib/mark-as-read-delay";
|
|
import { allowEmailMove, createMailboxWithHooks, emitEmailMove, idsFromSelectionKeys, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks";
|
|
import { emailSummary, emailToReadView } from "@/lib/plugin-projection";
|
|
import { resolveThreadRoute } from "@/lib/thread-routing";
|
|
import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
|
import { provideSearchResults, type ProvidedSearchResult } from "@/lib/plugin-search-results";
|
|
import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, trashAndJunkIds, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, unifiedServerKey, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
|
import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { pathNamesMailFolder } from "@/lib/deep-links";
|
|
import { currentStoreEpoch } from "@/lib/store-epoch";
|
|
import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderList, type FolderList } from "@/lib/folder-list-cache";
|
|
import { keywordPointer } from "@/lib/jmap/patch-pointer";
|
|
import { useAccountStore } from "@/stores/account-store";
|
|
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
|
import { SendCancelledCleanupError } from "@/lib/send-failure-message";
|
|
import type { StorageQuota } from "@/lib/quota";
|
|
import { isArchiveActionEnabled } from "@/lib/archive-gate";
|
|
import {
|
|
READ_FILTER, UNREAD_FILTER, UNREAD_SECTION_SIZE, andFilters, isUnreadFirstView, mergeUnreadSection,
|
|
sectionFromUnreadRows, unreadSectionRowCount, withoutReached, type UnreadSection,
|
|
} from "@/lib/unread-first";
|
|
|
|
type ScheduledSubmissionMetadata = {
|
|
submissionId: string;
|
|
sendAt: string;
|
|
identityId: string;
|
|
undoStatus: 'pending' | 'final' | 'canceled';
|
|
/** Owning JMAP account when the submission lives in a shared account. */
|
|
accountId?: string;
|
|
};
|
|
|
|
const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__';
|
|
|
|
type PendingUndoSend = {
|
|
submissionId: string;
|
|
emailId?: string;
|
|
identityId?: string;
|
|
sendAt: string;
|
|
isSmime: boolean;
|
|
/** Local account that owns the submission, when the message was sent from
|
|
* an identity belonging to a non-active account (#461). */
|
|
localAccountId?: string;
|
|
/** JMAP account holding the submission — the shared account when the message
|
|
* was sent from a shared identity (#874). Distinct from localAccountId,
|
|
* which selects *which login's client* to talk to. */
|
|
submissionAccountId?: string;
|
|
};
|
|
|
|
/** Drop the matching emails from a key-based selection. */
|
|
function dropFromSelection(
|
|
state: { emails: Email[]; selectedEmailKeys: Set<string> },
|
|
matches: (email: Email) => boolean,
|
|
): Set<string> {
|
|
const next = new Set(state.selectedEmailKeys);
|
|
for (const email of state.emails) if (matches(email)) next.delete(emailKeyFor(email));
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* The emails behind a key-based selection, each once. Looks in the list and in
|
|
* the loaded members of expanded threads: a thread member ticked in a folder
|
|
* view may live in another folder and so never be a list row.
|
|
*/
|
|
export function resolveSelectedEmails(state: {
|
|
emails: Email[];
|
|
threadEmailsCache: Map<string, Email[]>;
|
|
selectedEmailKeys: Set<string>;
|
|
}): Email[] {
|
|
const { selectedEmailKeys } = state;
|
|
if (selectedEmailKeys.size === 0) return [];
|
|
const found = new Map<string, Email>();
|
|
const visit = (email: Email) => {
|
|
const key = emailKeyFor(email);
|
|
if (selectedEmailKeys.has(key) && !found.has(key)) found.set(key, email);
|
|
};
|
|
state.emails.forEach(visit);
|
|
for (const members of state.threadEmailsCache.values()) members.forEach(visit);
|
|
return [...found.values()];
|
|
}
|
|
|
|
/** Enough of an email to key a selection by its owning account. */
|
|
type SelectableEmail = Pick<Email, 'id' | 'sourceClientAccountId' | 'sourceAccountId'>;
|
|
|
|
/**
|
|
* Explicit messages for a batch action, instead of the list selection. A
|
|
* filter rule applied to a folder reaches messages the list never loaded, so
|
|
* they cannot be routed by the list's source stamps.
|
|
*/
|
|
export interface BatchActionTarget {
|
|
emailIds: string[];
|
|
/** The login client that reaches the messages. */
|
|
client: IJMAPClient;
|
|
/** JMAP account the messages belong to. */
|
|
accountId?: string;
|
|
}
|
|
|
|
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: StorageQuota | null;
|
|
processingReadStatus: Set<string>; // Track emails being marked as read/unread
|
|
selectedEmailKeys: Set<string>; // Track selected emails for batch operations
|
|
// Emails the user just read (unread view) or unstarred (starred view) that
|
|
// should stay visible in that self-filtering cross view until it is re-opened,
|
|
// instead of vanishing on the next push refresh. In a folder sorted on a
|
|
// keyword (#718): the open conversation's rows and the held ones (listHold),
|
|
// kept although a read/star/tag change moved them past the loaded part of
|
|
// the server order. Cleared on navigation.
|
|
retainedInViewIds: Set<string>;
|
|
hasMoreEmails: boolean; // Track if more emails are available to load
|
|
totalEmails: number; // Total number of emails in the current mailbox/query
|
|
// The configured message-list order the current folder view was fetched
|
|
// with (#718); empty for chronological. Thread grouping mirrors it.
|
|
listOrder: SortLevel[];
|
|
// Whether the list keeps the server's order for listOrder's sender and
|
|
// subject levels: a folder of an account whose server collates them itself
|
|
// (Stalwart 1.0, see JMAPClient.keepsServerTextOrder). Otherwise thread
|
|
// grouping re-sorts them on the client, as the fetch did per page.
|
|
listKeepsServerTextOrder: boolean;
|
|
// The list row opened last and the keywords its messages had then. Its
|
|
// messages sort by those, so reading one in an "unread first" list leaves
|
|
// the row where it was clicked; opening another row releases it. Cleared
|
|
// on navigation.
|
|
listHold: ListHold | null;
|
|
isPushConnected: boolean; // Track if push notifications are connected
|
|
lastPushUpdate: number | null; // Timestamp of last push update
|
|
// Delta sync (RFC 8620 §5.2). The Email collection state the plain
|
|
// single-folder list (`emails`) was last synced at, with the folder it was
|
|
// loaded for; null whenever the list holds anything else (search, tag,
|
|
// category tab, unified/cross view). A push is then resolved with
|
|
// Email/changes against this state instead of re-querying the first page.
|
|
emailListSync: EmailListSync | null;
|
|
// Mountain View's "Unread first" inbox (lib/unread-first.ts): which rows of
|
|
// `emails` are the Unread section's. Null in every other view.
|
|
unreadSection: UnreadSection | null;
|
|
// Mailbox collection state per JMAP account the sidebar list was last
|
|
// synced at, so a Mailbox push can be resolved with Mailbox/changes.
|
|
mailboxSyncStates: Record<string, string>;
|
|
newEmailNotification: Email | null; // New email notification for toast
|
|
|
|
// Thread expansion state
|
|
// Thread state is keyed by the account-scoped thread key (threadKeyFor), not
|
|
// the bare JMAP id: in aggregate views the same id names unrelated threads
|
|
// in different accounts. Same for threadEmailCounts below.
|
|
expandedThreadIds: Set<string>;
|
|
threadEmailsCache: Map<string, Email[]>;
|
|
isLoadingThread: string | null;
|
|
// Full thread email counts (from Thread/get across all folders)
|
|
threadEmailCounts: Map<string, number>;
|
|
|
|
// Keyword/tag filter
|
|
selectedKeyword: string | null;
|
|
tagCounts: Record<string, { total: number; unread: number }>;
|
|
|
|
// Advanced search state
|
|
searchFilters: SearchFilters;
|
|
// Folder the search is scoped to; "" means all folders. Deliberately kept
|
|
// separate from selectedMailbox so that opening the search panel inside a
|
|
// folder does not silently narrow the search to it, and so an explicit
|
|
// choice survives navigating the mail list (#788).
|
|
searchMailboxId: string;
|
|
// Set when a folder's unread count scoped the search to that folder: the
|
|
// scope then moves with the open folder, the way the search itself does
|
|
// (#553). A scope picked in the dropdown or a filter reset clears it.
|
|
searchScopeFollowsFolder: boolean;
|
|
isAdvancedSearchOpen: boolean;
|
|
searchAbortController: AbortController | null;
|
|
/** Plugins' results from elsewhere for the search running (onProvideSearchResults), drawn above its list. */
|
|
externalSearchResults: ProvidedSearchResult[];
|
|
|
|
// Unified mailbox state
|
|
// Bumped by any action that changes which mailbox/keyword/unified view is
|
|
// active. fetchUnifiedEmails/fetchCrossView capture it before their async
|
|
// fetch and check it on return, so a late-resolving fetch from a view the
|
|
// user already navigated away from can't stomp the newer view's state (#1102).
|
|
// Callers that await before starting one of those fetches check it with
|
|
// captureViewToken().
|
|
viewToken: number;
|
|
isUnifiedView: boolean;
|
|
unifiedRole: UnifiedMailboxRole | null;
|
|
// Cross-account view ('unread' | 'starred' | 'all') when active; null for the
|
|
// per-role unified views. Mutually exclusive with unifiedRole; both run under
|
|
// isUnifiedView.
|
|
crossView: CrossView | null;
|
|
unifiedErrors: Map<string, string>; // accountId -> error message
|
|
// Unified-section sidebar badges. These are NOT an independent source of
|
|
// truth: they are a pure projection of the live per-account mailbox lists
|
|
// (`mailboxes` + `accountMailboxes`) over the last-known unified scope
|
|
// (`unifiedScope`). Recomputed automatically whenever those lists change (see
|
|
// the store subscription below), so optimistic delete/move/markRead patches and
|
|
// push-driven mailbox refreshes flow into the badges without a server round
|
|
// trip. (#281 follow-up: single source of truth for unified counters.)
|
|
unifiedCounts: UnifiedMailboxCounts[];
|
|
// Unread total across the cross-view included folders (badge for unread/all).
|
|
crossUnreadCount: number;
|
|
// The account/folder structure (which accounts, role mailboxes, cross-include
|
|
// selection) that the unified badges are projected over. Set by
|
|
// refreshUnifiedCounts/refreshCrossCounts from the freshly-built
|
|
// UnifiedAccountClient[]. The COUNTER values it carries are ignored at
|
|
// projection time - live counters are read from `mailboxes`/`accountMailboxes`
|
|
// instead - so a stale snapshot here only affects structure, never numbers.
|
|
unifiedScope: UnifiedAccountClient[];
|
|
|
|
// Scheduled send state
|
|
scheduledEmails: ScheduledEmail[];
|
|
scheduledEmailIds: Set<string>;
|
|
/** By login, account and email id (scheduledEmailKey). */
|
|
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>;
|
|
scheduledTotal: number;
|
|
scheduledHasMore: boolean;
|
|
scheduledNextPosition: number;
|
|
/** Pending scheduled-send counts per JMAP account (server-side, all pages). */
|
|
scheduledTotalByAccount: Record<string, number>;
|
|
isLoadingScheduled: boolean;
|
|
isScheduledView: boolean;
|
|
/**
|
|
* When set, the scheduled view is narrowed to one JMAP account — the shared
|
|
* account whose "Scheduled" row was clicked. null = the combined view.
|
|
*/
|
|
scheduledAccountScope: string | null;
|
|
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. With
|
|
* `clientAccountId`, `accountId` is a JMAP account reached through that
|
|
* login, and the list is also stored as that source's (sourceMailboxes).
|
|
*/
|
|
fetchAccountMailboxes: (client: IJMAPClient, accountId: string, clientAccountId?: string) => Promise<void>;
|
|
selectEmail: (email: Email | null) => void;
|
|
/**
|
|
* Shows a list row in the viewer while its body is on its way: selects it
|
|
* and marks the body loading in one store write, so the click renders once.
|
|
* Plugins hear onEmailOpen only from finishOpenEmail, with the full message:
|
|
* a row has no body, headers or attachments to show them.
|
|
*/
|
|
beginOpenEmail: (row: Email) => void;
|
|
/** The fetched message replaces the row beginOpenEmail showed (one write). */
|
|
finishOpenEmail: (email: Email) => void;
|
|
/** The fetch failed: back to what was open before, without the loading state. */
|
|
abandonOpenEmail: (previous: Email | null) => void;
|
|
/** Holds a list row in place (see listHold); `rowKey` as from listRowKey. */
|
|
holdListRow: (rowKey: string) => void;
|
|
selectMailbox: (mailboxId: string) => void;
|
|
setLoading: (loading: boolean) => void;
|
|
setLoadingEmail: (loading: boolean) => void;
|
|
setError: (error: string | null) => void;
|
|
setSearchQuery: (query: string) => void;
|
|
setQuota: (quota: StorageQuota | null) => void;
|
|
selectKeyword: (keyword: string | null) => void;
|
|
fetchTagCounts: (client: IJMAPClient) => Promise<void>;
|
|
/** Take the email, not its id: the id alone collides across accounts. */
|
|
toggleEmailSelection: (email: SelectableEmail) => void;
|
|
selectRangeEmails: (target: SelectableEmail) => void;
|
|
/**
|
|
* Shift+Arrow (and Shift+J/K): grow or shrink the selection by one row,
|
|
* from the open or last ticked message, like a desktop mail client. (#992)
|
|
*/
|
|
extendSelection: (direction: 1 | -1) => void;
|
|
lastSelectedEmailKey: string | null;
|
|
/**
|
|
* The moving end of a keyboard range and the anchor it was taken from.
|
|
* Only valid while the anchor is still lastSelectedEmailKey.
|
|
*/
|
|
selectionFocus: { anchor: string; focus: string } | null;
|
|
/**
|
|
* The message list's rows in screen order, each with the keys a click on it
|
|
* selects. Shift-click ranges walk these, so a thread is in a range only
|
|
* when its row is, not whenever one of its messages falls between the two
|
|
* ends by date. (#1230)
|
|
*/
|
|
listRows: ListRow[] | null;
|
|
setListRows: (rows: ListRow[] | null) => void;
|
|
selectAllEmails: () => void;
|
|
clearSelection: () => void;
|
|
|
|
// JMAP operations
|
|
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
|
|
fetchEmails: (client: IJMAPClient, mailboxId?: string, opts?: { background?: boolean }) => 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, options?: { requestReadReceipt?: boolean; requestDsn?: boolean; requireTls?: boolean; priority?: MessagePriority; localAccountId?: string; draftAccountId?: string }) => Promise<SendEmailResult>;
|
|
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise<SendEmailResult>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean, emailKey?: string) => Promise<boolean>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
markAsRead: (client: IJMAPClient, emailId: string, read: boolean, emailKey?: string) => Promise<boolean>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string) => Promise<boolean>;
|
|
/**
|
|
* Move a single email, routing across the account boundary when the
|
|
* destination folder is owned by a different JMAP account (a delegated/shared
|
|
* mailbox, or a different connected account) — the "Move to" context-menu
|
|
* equivalent of what drag-and-drop already does. Falls back to the plain
|
|
* single-account `moveToMailbox` when source and destination share an account.
|
|
*/
|
|
moveToMailboxCrossAware: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string) => Promise<boolean>;
|
|
/**
|
|
* `destLocalAccountId`: the login whose folder list `mailboxId` is from (a
|
|
* drop target in the multi-account sidebar); by default the active one's.
|
|
* `rows`: the rows that were dragged. Only a list that mixes accounts needs
|
|
* them: an id alone can name rows of several accounts there.
|
|
*/
|
|
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string, destLocalAccountId?: string | null, rows?: Email[]) => Promise<boolean>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string, emailKey?: string) => Promise<boolean>;
|
|
/**
|
|
* Take messages out of the open folder only, leaving them in their other
|
|
* folders (#615). Acts on those of `emails` that removalFolder and
|
|
* canRemoveFromFolder allow; resolves false when none qualify or a plugin
|
|
* said no.
|
|
*/
|
|
removeFromMailbox: (client: IJMAPClient, emails: Email[]) => Promise<boolean>;
|
|
/**
|
|
* 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).
|
|
* `sourceJmapAccountId` is the mirror image for the source side: when the
|
|
* emails live in a delegated/shared mailbox accessed through the source
|
|
* client, the copy/delete must target the owner's JMAP account rather than
|
|
* the source client's primary one.
|
|
* `options.keepOriginal` turns the move into a copy: the originals stay in
|
|
* place and in the current view.
|
|
*/
|
|
crossAccountMoveEmails: (
|
|
emailIdsBySource: Map<string, string[]>,
|
|
destAccountId: string,
|
|
destMailboxId: string,
|
|
destJmapAccountId?: string,
|
|
sourceJmapAccountId?: string,
|
|
options?: { keepOriginal?: boolean },
|
|
) => Promise<boolean>;
|
|
/**
|
|
* Copy emails into a folder of another connected account, keeping the
|
|
* originals. `destAccountId` is the destination login (AccountEntry.id) and
|
|
* `destMailboxId` the raw JMAP id of one of its own folders. Emails are
|
|
* grouped by the login and JMAP account they live in, then copied through
|
|
* crossAccountMoveEmails with `keepOriginal`.
|
|
*/
|
|
/** Pass emails rather than ids where you have them: an id alone can name another account's namesake. */
|
|
copyEmailsToAccount: (emails: Array<string | Email>, destAccountId: string, destMailboxId: string) => Promise<void>;
|
|
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
|
/**
|
|
* Runs the search held in searchQuery/searchFilters. `transformed` is the
|
|
* query and filters onSearch already returned for this search (searchEmails
|
|
* hands over when a plugin added filters), so plugins are not asked twice.
|
|
*/
|
|
advancedSearch: (client: IJMAPClient, transformed?: { query: string; filters: SearchFilters }) => Promise<void>;
|
|
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
|
setSearchMailboxId: (mailboxId: string) => void;
|
|
scopeSearchToOpenFolder: () => void;
|
|
clearSearchFilters: () => void;
|
|
toggleAdvancedSearch: () => void;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
toggleStar: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise<boolean>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
setEmailKeywords: (client: IJMAPClient, emailId: string, keywords: Record<string, boolean>, emailKey?: string) => Promise<boolean>;
|
|
/**
|
|
* Set (true) or clear (false) just the named keywords. Every other keyword
|
|
* is left as the server has it.
|
|
*/
|
|
patchEmailKeywords: (client: IJMAPClient, emailId: string, changes: Record<string, boolean>, emailKey?: string) => Promise<boolean>;
|
|
/**
|
|
* `source`: the login and account the message was read in, taken where the
|
|
* reply, forward or receipt started. By the time it is sent another folder
|
|
* may be open, with another account's message under the same id.
|
|
*/
|
|
markEmailKeyword: (client: IJMAPClient, emailId: string, keyword: string, source?: EmailWriteSource) => Promise<void>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
setEmailKeywordsLocal: (emailId: string, keywords: Record<string, boolean>, emailKey?: string) => void;
|
|
|
|
// Batch operations
|
|
/** Acts on the selection, or on `target`'s messages when given. */
|
|
batchMarkAsRead: (client: IJMAPClient, read: boolean, target?: BatchActionTarget) => Promise<boolean>;
|
|
/**
|
|
* Put a tag on, or take it off, every selected message. Their other tags
|
|
* and keywords stay as they are, and so does the selection, so several
|
|
* tags can be applied in a row. Rejects when a write fails. (#1077)
|
|
*/
|
|
batchSetTag: (client: IJMAPClient, tagId: string, add: boolean) => Promise<boolean>;
|
|
batchDelete:(client: IJMAPClient, permanent?: boolean) => Promise<boolean>;
|
|
/**
|
|
* Acts on the selection, or on `target`'s messages when given; with a
|
|
* target, `mailboxId` is the destination's JMAP id in that account.
|
|
*/
|
|
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string, target?: BatchActionTarget) => Promise<boolean>;
|
|
batchArchive: (client: IJMAPClient) => Promise<boolean>;
|
|
|
|
// Spam operations
|
|
// For unified-view emails the undo must hit the same account: `accountId` is the
|
|
// owning JMAP account (passed to JMAP), `sourceClientAccountId` is the login the
|
|
// email is reachable through (used to pick the right client). (#281)
|
|
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string; sourceClientAccountId?: string }>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
markAsSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise<boolean>;
|
|
/** `emailKey` (emailKeyFor of the row) picks the right row when the id repeats across accounts. */
|
|
undoSpam: (client: IJMAPClient, emailId: string, emailKey?: string) => Promise<boolean>;
|
|
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<boolean>;
|
|
batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise<boolean>;
|
|
|
|
// Push notification handlers
|
|
setPushConnected: (connected: boolean) => void;
|
|
handleStateChange: (change: StateChange, client: IJMAPClient) => Promise<void>;
|
|
/**
|
|
* Resolve an Email push for the current list with Email/changes: drop
|
|
* destroyed rows, re-fetch and patch updated ones in place, and only fall
|
|
* back to a full first-page refresh (returns false) when mail entered the
|
|
* view, the delta is too large, or the view is not a plain folder list.
|
|
*/
|
|
applyEmailDelta: (client: IJMAPClient, newState: string) => Promise<boolean>;
|
|
/**
|
|
* Resolve a Mailbox push with Mailbox/changes: re-fetch only the folders
|
|
* reported as updated (typically just their counters) and patch them into
|
|
* the sidebar list. Returns false when a full refetch is needed (folders
|
|
* created/destroyed, unknown sync state, delta unavailable).
|
|
*/
|
|
applyMailboxDelta: (client: IJMAPClient, changed: StateChange['changed']) => Promise<boolean>;
|
|
refreshCurrentMailbox: (client: IJMAPClient) => Promise<void>;
|
|
handleNewEmailNotification: (email: Email) => void;
|
|
clearNewEmailNotification: () => void;
|
|
|
|
// Thread expansion actions
|
|
toggleThreadExpansion: (threadKey: string) => void;
|
|
fetchThreadEmails: (client: IJMAPClient, threadKey: string) => Promise<Email[]>;
|
|
collapseAllThreads: () => void;
|
|
updateThreadCache: (threadId: string, emails: Email[]) => void;
|
|
fetchThreadEmailCounts: (client: IJMAPClient) => Promise<void>;
|
|
markThreadAsRead: (client: IJMAPClient, threadKey: string) => Promise<void>;
|
|
/**
|
|
* Marks an expanded thread read the way the reading pane marks a message:
|
|
* following `markAsReadDelay` (never / instantly / after a delay). A delayed
|
|
* mark is dropped when the thread is collapsed first.
|
|
*/
|
|
autoMarkExpandedThreadAsRead: (client: IJMAPClient, threadKey: string) => void;
|
|
/**
|
|
* Drops a still-pending delayed mark for the thread, without collapsing it.
|
|
* Used when the user moves on to a message outside that thread.
|
|
*/
|
|
cancelAutoMarkThreadAsRead: (threadKey: string) => void;
|
|
|
|
// Mailbox management
|
|
createMailbox: (client: IJMAPClient, name: string, parentId?: string, accountId?: string) => Promise<boolean>;
|
|
renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<boolean>;
|
|
deleteMailbox: (client: IJMAPClient, mailboxId: string, options?: { removeEmails?: boolean }) => Promise<boolean>;
|
|
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
|
|
reorderMailboxes: (client: IJMAPClient, orderedIds: string[]) => Promise<void>;
|
|
moveMailbox: (client: IJMAPClient, mailboxId: string, newParentId: string | null, orderedSiblingIds?: string[]) => Promise<void>;
|
|
/** Reparents several folders at once; resolves with the ids the server refused. */
|
|
moveMailboxes: (client: IJMAPClient, mailboxIds: string[], newParentId: string | null) => Promise<string[]>;
|
|
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<boolean>;
|
|
/** How many messages were marked read; null when a plugin stopped it. */
|
|
markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise<number | null>;
|
|
/** Every unread message in the account; null when a plugin stopped it. */
|
|
markAllMailboxesAsRead: (client: IJMAPClient) => Promise<number | null>;
|
|
|
|
// Unified mailbox operations
|
|
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
|
|
loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise<void>;
|
|
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
|
|
exitUnifiedView: () => void;
|
|
// Cross-account view operations (unread / starred / all)
|
|
fetchCrossView: (accounts: UnifiedAccountClient[], view: CrossView) => Promise<void>;
|
|
refreshCrossCounts: (accounts: UnifiedAccountClient[]) => 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>;
|
|
/** `accountId`: the row's scheduledAccountId. Submission ids repeat across accounts. */
|
|
rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string, accountId?: string) => Promise<SendEmailResult>;
|
|
cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise<Email | null>;
|
|
clearPendingUndoSend: () => void;
|
|
refreshScheduledMetadata: (client: IJMAPClient) => Promise<void>;
|
|
setScheduledView: (isScheduledView: boolean, accountScope?: string | null) => 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;
|
|
}
|
|
|
|
/**
|
|
* Matches the list row a single-email action is about. Email ids repeat across
|
|
* accounts, so in a merged view (unified inbox, tags) the bare id also matches
|
|
* the other account's namesake: deleting one row moved or hid the other as
|
|
* well (#857). With the row's `emailKeyFor` key only that row matches.
|
|
*/
|
|
function singleEmailMatcher(emailId: string, emailKey?: string): (email: Email) => boolean {
|
|
return emailKey ? (email) => emailKeyFor(email) === emailKey : (email) => email.id === emailId;
|
|
}
|
|
|
|
/** getNextSelectedEmail for the one row `isRemoved` matches. */
|
|
function getNextSelectedEmailAfterRemovingRow(
|
|
state: { emails: Email[]; selectedEmail: Email | null },
|
|
isRemoved: (email: Email) => boolean,
|
|
): Email | null {
|
|
const selected = state.selectedEmail;
|
|
if (!selected || !isRemoved(selected)) return selected;
|
|
|
|
const selectedKey = emailKeyFor(selected);
|
|
let idx = state.emails.findIndex(e => emailKeyFor(e) === selectedKey);
|
|
if (idx === -1) idx = state.emails.findIndex(e => e.id === selected.id);
|
|
if (idx === -1) return null;
|
|
|
|
for (let nextIndex = idx + 1; nextIndex < state.emails.length; nextIndex++) {
|
|
if (!isRemoved(state.emails[nextIndex])) return state.emails[nextIndex];
|
|
}
|
|
for (let prevIndex = idx - 1; prevIndex >= 0; prevIndex--) {
|
|
if (!isRemoved(state.emails[prevIndex])) return state.emails[prevIndex];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function annotateScheduledEmails(
|
|
emails: Email[],
|
|
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
|
): Email[] {
|
|
if (scheduledSubmissionByEmailId.size === 0) return emails;
|
|
return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId));
|
|
}
|
|
|
|
/**
|
|
* Key of a scheduled send in `scheduledSubmissionByEmailId`: the login and the
|
|
* JMAP account of the message, and its id. Email ids repeat across accounts,
|
|
* so by the bare id a row of another account got the badge, and its Send now,
|
|
* Reschedule and Cancel acted on this message's submission.
|
|
*/
|
|
function scheduledEmailKey(clientAccountId: string | undefined, accountId: string | undefined, emailId: string): string {
|
|
return `${clientAccountId ?? ''}|${accountId ?? ''}|${emailId}`;
|
|
}
|
|
|
|
/** The scheduled sends `client` listed, by scheduledEmailKey. */
|
|
function scheduledSubmissionMap(client: IJMAPClient, emails: ScheduledEmail[]): Map<string, ScheduledSubmissionMetadata> {
|
|
const auth = useAuthStore.getState();
|
|
const login = [...auth.getAllConnectedClients()].find(([, c]) => c === client)?.[0] ?? auth.activeAccountId ?? undefined;
|
|
return new Map(emails.map(email => [scheduledEmailKey(login, email.scheduledAccountId ?? client.getAccountId(), email.id), {
|
|
submissionId: email.emailSubmissionId,
|
|
sendAt: email.scheduledSendAt,
|
|
identityId: email.scheduledIdentityId,
|
|
undoStatus: email.scheduledUndoStatus,
|
|
accountId: email.scheduledAccountId,
|
|
}]));
|
|
}
|
|
|
|
function annotateScheduledEmail(
|
|
email: Email,
|
|
scheduledSubmissionByEmailId: Map<string, ScheduledSubmissionMetadata>
|
|
): Email {
|
|
// The row's login and account: its stamps, else the folder on screen.
|
|
const { client, clientAccountId, accountId } = resolveEmailBlobContext(email, useAuthStore.getState().client);
|
|
const scheduled = scheduledSubmissionByEmailId.get(scheduledEmailKey(clientAccountId, accountId ?? client?.getAccountId(), email.id));
|
|
if (!scheduled) return email;
|
|
return {
|
|
...email,
|
|
scheduledSendAt: scheduled.sendAt,
|
|
emailSubmissionId: scheduled.submissionId,
|
|
scheduledIdentityId: scheduled.identityId,
|
|
scheduledUndoStatus: scheduled.undoStatus,
|
|
scheduledAccountId: scheduled.accountId,
|
|
isScheduled: true,
|
|
};
|
|
}
|
|
|
|
/** One scheduled send: submission and email ids repeat across accounts. */
|
|
function isSameScheduledSend(a: ScheduledEmail, b: ScheduledEmail): boolean {
|
|
return a.emailSubmissionId === b.emailSubmissionId && a.scheduledAccountId === b.scheduledAccountId;
|
|
}
|
|
|
|
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.
|
|
*/
|
|
// ---------------------------------------------------------------------------
|
|
// In-flight refresh coalescing
|
|
//
|
|
// One push event fans out into the same refreshes back to back: a drop
|
|
// handler's refreshCurrentMailbox and the push echo's, fetchMailboxes for the
|
|
// Mailbox change, fetchTagCounts, fetchThreadEmailCounts - and every open tab
|
|
// repeats the set. Stalwart caps parallel requests per user
|
|
// (maxConcurrentRequests, default 4) and refuses the surplus with
|
|
// jmap:error:limit, which used to blank the sidebar's folder tree (#780).
|
|
//
|
|
// Callers that arrive while a run is in flight share its promise, and at most
|
|
// ONE follow-up run is queued so a caller whose state change postdates the
|
|
// in-flight request still ends up seeing fresh data. Keyed per client so two
|
|
// accounts' refreshes never collapse into each other.
|
|
// ---------------------------------------------------------------------------
|
|
type InFlightRefresh = { promise: Promise<void>; rerun: boolean };
|
|
const inFlightRefreshes = new WeakMap<object, Map<string, InFlightRefresh>>();
|
|
|
|
/**
|
|
* Drafts and Sent of the account a submission belongs to. A message sent or
|
|
* scheduled from a shared or group identity lives in that account, so
|
|
* restoring it to Drafts must use that account's Drafts - the store's own
|
|
* list is the login account's (plus shared mailboxes under namespaced ids).
|
|
*/
|
|
async function submissionMailboxes(
|
|
client: IJMAPClient,
|
|
storeMailboxes: Mailbox[],
|
|
accountId: string | undefined,
|
|
): Promise<{ draftsMailbox?: Mailbox; sentMailbox?: Mailbox }> {
|
|
const ownAccount = !accountId || accountId === client.getAccountId();
|
|
const mailboxes = ownAccount
|
|
? (storeMailboxes.length > 0 ? storeMailboxes : await client.getMailboxes()).filter(mb => !mb.isShared)
|
|
: await client.getMailboxes(accountId);
|
|
return {
|
|
draftsMailbox: mailboxes.find(mb => mb.role === 'drafts'),
|
|
sentMailbox: mailboxes.find(mb => mb.role === 'sent'),
|
|
};
|
|
}
|
|
|
|
function coalesceRefresh(client: IJMAPClient, key: string, run: () => Promise<void>): Promise<void> {
|
|
let byKey = inFlightRefreshes.get(client);
|
|
if (!byKey) {
|
|
byKey = new Map();
|
|
inFlightRefreshes.set(client, byKey);
|
|
}
|
|
const existing = byKey.get(key);
|
|
if (existing) {
|
|
existing.rerun = true;
|
|
return existing.promise;
|
|
}
|
|
const entry: InFlightRefresh = { promise: Promise.resolve(), rerun: false };
|
|
const registry = byKey;
|
|
entry.promise = (async () => {
|
|
try {
|
|
await run();
|
|
while (entry.rerun) {
|
|
entry.rerun = false;
|
|
await run();
|
|
}
|
|
} finally {
|
|
registry.delete(key);
|
|
}
|
|
})();
|
|
registry.set(key, entry);
|
|
return entry.promise;
|
|
}
|
|
|
|
/** See EmailStore.listHold. */
|
|
export interface ListHold {
|
|
rowKey: string;
|
|
keywords: HeldKeywords;
|
|
}
|
|
|
|
/**
|
|
* The list row an email is shown in: its thread, or the email itself when the
|
|
* list is not threaded - the same key EmailList groups by.
|
|
*/
|
|
export function listRowKey(email: Email, isScheduledView: boolean): string {
|
|
return useSettingsStore.getState().disableThreading || isScheduledView ? email.id : threadKeyFor(email);
|
|
}
|
|
|
|
/** Delta-sync baseline of the plain folder list (see EmailStore.emailListSync). */
|
|
export interface EmailListSync {
|
|
/** Email collection state the list was last synced at. */
|
|
state: string;
|
|
/** JMAP account the folder belongs to (owner account for a shared folder). */
|
|
accountId: string;
|
|
/** Store id of the folder the list shows (namespaced for shared folders). */
|
|
mailboxId: string;
|
|
}
|
|
|
|
// Upper bound on the ids one Email/changes delta may report before the list
|
|
// is re-queried instead: past this a first-page fetch is cheaper than
|
|
// resolving every id. Also passed as `maxChanges`, so the server signals
|
|
// `hasMoreChanges` rather than returning a huge delta.
|
|
const EMAIL_DELTA_MAX_CHANGES = 200;
|
|
// Ids outside the loaded list (created, or updated elsewhere) that are
|
|
// resolved to see whether they entered the folder; more than this and the
|
|
// refresh path is used directly.
|
|
const EMAIL_DELTA_MAX_CANDIDATES = 50;
|
|
|
|
let emailDeltaQueue: Promise<void> = Promise.resolve();
|
|
|
|
type StoreGet = () => EmailStore;
|
|
type StoreSet = (partial: Partial<EmailStore> | ((state: EmailStore) => Partial<EmailStore>)) => void;
|
|
|
|
/** Whether this folder view is Mountain View's "Unread first" inbox. */
|
|
function unreadFirstFor(
|
|
mailbox: Mailbox | undefined,
|
|
s: Pick<EmailStore, 'selectedKeyword' | 'searchQuery' | 'searchFilters' | 'isUnifiedView'>,
|
|
): boolean {
|
|
const { interfaceLayout, mvInboxType } = useSettingsStore.getState();
|
|
return isUnreadFirstView({
|
|
interfaceLayout,
|
|
mvInboxType,
|
|
mailboxRole: mailbox?.role,
|
|
otherView: !!s.selectedKeyword || !!s.searchQuery || !isFilterEmpty(s.searchFilters) || s.isUnifiedView,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* What a folder's query ANDs in: the open category tab's filter, and read
|
|
* mail only in the "Unread first" inbox, whose unread mail is the section's.
|
|
*/
|
|
function folderListFilter(
|
|
mailbox: Mailbox | undefined,
|
|
s: Pick<EmailStore, 'selectedKeyword' | 'searchQuery' | 'searchFilters' | 'isUnifiedView'>,
|
|
): Record<string, unknown> | null {
|
|
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
|
|
return andFilters(categoryFilter, unreadFirstFor(mailbox, s) ? READ_FILTER : null);
|
|
}
|
|
|
|
/** The newest unread mail of the folder, for the "Unread first" inbox. */
|
|
async function loadUnreadSection(client: IJMAPClient, mailbox: Mailbox, get: StoreGet): Promise<{ emails: Email[]; total: number }> {
|
|
const effectiveClient = resolveActionClient(client);
|
|
const accountId = mailbox.isShared ? mailbox.accountId : undefined;
|
|
const categoryFilter = useMessageListTabsStore.getState().getCategoryFilter(mailbox.role);
|
|
const result = await effectiveClient.getEmails(
|
|
mailbox.originalId || mailbox.id,
|
|
accountId,
|
|
UNREAD_SECTION_SIZE,
|
|
0,
|
|
undefined,
|
|
true,
|
|
andFilters(categoryFilter, UNREAD_FILTER) ?? undefined,
|
|
[],
|
|
);
|
|
const emails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
return { emails: annotateScheduledEmails(emails, get().scheduledSubmissionByEmailId), total: result.total };
|
|
}
|
|
|
|
function notifyNewInboxMail(client: IJMAPClient, mailbox: Mailbox, email: Email, get: StoreGet) {
|
|
// A muted Inbox (or account) stays quiet in the app too (#1169).
|
|
const accountId = mailbox.accountId || resolveActionClient(client).getAccountId();
|
|
const muted = isMailboxMuted(
|
|
useSettingsStore.getState().notificationMutes, accountId, mailbox.originalId || mailbox.id,
|
|
);
|
|
if (!muted) get().handleNewEmailNotification(email);
|
|
}
|
|
|
|
function applyUnreadSection(get: StoreGet, set: StoreSet, mailboxId: string, loaded: { emails: Email[]; total: number }) {
|
|
const { emails, unreadSection, selectedEmail } = get();
|
|
const merged = mergeUnreadSection(emails, unreadSection, loaded.emails, selectedEmail ? threadKeyFor(selectedEmail) : null);
|
|
set({ emails: merged.emails, unreadSection: { mailboxId, ids: merged.ids, total: loaded.total } });
|
|
}
|
|
|
|
/**
|
|
* After a local read/star/tag change: re-query a folder list sorted on one of
|
|
* the changed keywords (#718). The change moves the row at once, possibly past
|
|
* the end of what is loaded, but nothing moves the rows from beyond it up -
|
|
* reading the top unread mail over and over ran out of unread mail after the
|
|
* first page while more waited on the server.
|
|
*/
|
|
function refillAfterKeywordChange(get: StoreGet, client: IJMAPClient, changed: Iterable<string>): void {
|
|
const s = get();
|
|
if (s.isUnifiedView || s.isScheduledView || s.selectedKeyword || s.searchQuery || !isFilterEmpty(s.searchFilters)) return;
|
|
const sortKeywords = orderKeywords(s.listOrder);
|
|
if (![...changed].some((keyword) => sortKeywords.includes(keyword))) return;
|
|
void s.refreshCurrentMailbox(client);
|
|
}
|
|
|
|
async function applyEmailDeltaNow(client: IJMAPClient, newState: string | null, get: StoreGet, set: StoreSet): Promise<boolean> {
|
|
const s = get();
|
|
const sync = s.emailListSync;
|
|
if (!sync) return false;
|
|
// Anything but the plain folder list the baseline was taken for is
|
|
// refreshed the old way: a server-side filter (search, tag, category tab)
|
|
// cannot be re-evaluated from a list of changed ids, and the unified /
|
|
// cross-account views fan out over several accounts.
|
|
if (
|
|
s.isUnifiedView ||
|
|
s.isScheduledView ||
|
|
s.selectedKeyword ||
|
|
s.searchQuery ||
|
|
!isFilterEmpty(s.searchFilters) ||
|
|
s.selectedMailbox !== sync.mailboxId
|
|
) {
|
|
return false;
|
|
}
|
|
const mailboxes = resolveActionMailboxes();
|
|
const mailbox = mailboxes.find((mb) => mb.id === sync.mailboxId);
|
|
if (folderListFilter(mailbox, s)) return false;
|
|
const effectiveClient = resolveActionClient(client);
|
|
if (!effectiveClient.getEmailChanges) return false;
|
|
// The push carries the state we already hold (our own Email/set raised it,
|
|
// or a duplicate notification): nothing to do, and no request either. A
|
|
// null newState (a folder repainted from memory) only the server can judge.
|
|
if (newState !== null && sync.state === newState) return true;
|
|
|
|
const accountArg = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
const delta = await effectiveClient.getEmailChanges(sync.state, accountArg, EMAIL_DELTA_MAX_CHANGES);
|
|
if (!delta || delta.hasMoreChanges) return false;
|
|
|
|
const listIds = new Set(s.emails.map((e) => e.id));
|
|
const destroyedInList = delta.destroyed.filter((id) => listIds.has(id));
|
|
const updatedInList = delta.updated.filter((id) => listIds.has(id));
|
|
// Ids we don't show may have entered the folder (new mail, a move into it,
|
|
// an undelete). Resolve them; if any lands here, the first page must be
|
|
// re-queried so it is inserted at its sorted position and the new-mail
|
|
// notification fires as before.
|
|
const candidates = [...delta.created, ...delta.updated.filter((id) => !listIds.has(id))];
|
|
if (candidates.length > EMAIL_DELTA_MAX_CANDIDATES) return false;
|
|
if (candidates.length > 0) {
|
|
const arrived = await effectiveClient.getSomeEmails(candidates, accountArg);
|
|
if (arrived.some((e) => e.mailboxIds?.[sync.mailboxId])) return false;
|
|
}
|
|
|
|
const removed = new Set(destroyedInList);
|
|
const updatedEmails = updatedInList.length > 0 ? await effectiveClient.getSomeEmails(updatedInList, accountArg) : [];
|
|
const updatedById = new Map(updatedEmails.map((e) => [e.id, e]));
|
|
// Another client changed the read/star/tag state a keyword order (#718)
|
|
// sorts on: the row may now belong past the loaded part of the list and an
|
|
// unloaded one in its place, which patching rows in place cannot show.
|
|
const sortKeywords = orderKeywords(s.listOrder);
|
|
if (sortKeywords.length > 0) {
|
|
const localById = new Map(s.emails.map((e) => [e.id, e]));
|
|
const moved = updatedEmails.some((fresh) => {
|
|
const local = localById.get(fresh.id);
|
|
return local && sortKeywords.some((k) => !!local.keywords?.[k] !== !!fresh.keywords?.[k]);
|
|
});
|
|
if (moved) return false;
|
|
}
|
|
for (const id of updatedInList) {
|
|
const fresh = updatedById.get(id);
|
|
// Gone from the folder (moved/deleted) or gone entirely since the delta.
|
|
if (!fresh || !fresh.mailboxIds?.[sync.mailboxId]) removed.add(id);
|
|
}
|
|
const replacements = await emailHooks.onEmailsFetched.transform(
|
|
[...updatedById.values()].filter((e) => !removed.has(e.id)),
|
|
);
|
|
const replacementById = new Map(
|
|
annotateScheduledEmails(replacements, get().scheduledSubmissionByEmailId).map((e) => [e.id, e]),
|
|
);
|
|
|
|
// Nothing on the page changed: advance the baseline and leave the list be,
|
|
// rather than hand it a new array that renders the same rows again.
|
|
if (removed.size === 0 && replacementById.size === 0) {
|
|
set((state) => ({
|
|
emailListSync: state.emailListSync?.mailboxId === sync.mailboxId ? { ...sync, state: delta.newState } : state.emailListSync,
|
|
}));
|
|
return true;
|
|
}
|
|
|
|
// Re-read the list: the requests above may have overlapped a user action.
|
|
const current = get().emails;
|
|
const next = current
|
|
.filter((e) => !removed.has(e.id))
|
|
.map((e) => replacementById.get(e.id) ?? e);
|
|
const changedThreadIds = new Set<string>();
|
|
for (const e of current) {
|
|
if (removed.has(e.id) || replacementById.has(e.id)) changedThreadIds.add(threadKeyFor(e));
|
|
}
|
|
for (const e of replacementById.values()) changedThreadIds.add(threadKeyFor(e));
|
|
|
|
set((state) => {
|
|
const threadEmailsCache = new Map(state.threadEmailsCache);
|
|
for (const tid of changedThreadIds) threadEmailsCache.delete(tid);
|
|
const totalEmails = Math.max(state.totalEmails - (current.length - next.length), next.length);
|
|
return {
|
|
emails: next,
|
|
totalEmails,
|
|
hasMoreEmails: state.hasMoreEmails && next.length < totalEmails,
|
|
threadEmailsCache,
|
|
emailListSync: state.emailListSync?.mailboxId === sync.mailboxId ? { ...sync, state: delta.newState } : state.emailListSync,
|
|
};
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// ─── New mail for plugins (onNewEmailReceived) ───────────────────────────────
|
|
//
|
|
// A message is new mail when Email/changes reports it as created and it sits
|
|
// in the Inbox. A move into the Inbox (undo spam, a drag, unarchive) keeps
|
|
// its id and shows up as updated, and the initial load is the baseline, so
|
|
// neither is reported. The check runs on its own baseline, independent of
|
|
// whichever folder or view is open.
|
|
|
|
/** Email state per JMAP account up to which new mail was reported. */
|
|
const newMailBaselines = new Map<string, string>();
|
|
/** Recently reported ids, so an overlapping delta never reports one twice. */
|
|
const reportedNewMail: string[] = [];
|
|
let newMailQueue: Promise<void> = Promise.resolve();
|
|
const NEW_MAIL_MAX_PAGES = 5;
|
|
|
|
function reportNewInboxMail(client: IJMAPClient, accountId: string, pushedState: string, seedState: string | undefined, get: StoreGet): Promise<void> {
|
|
const run = async () => {
|
|
const since = newMailBaselines.get(accountId) ?? seedState;
|
|
// Nobody listens: keep the baseline current so a plugin that starts
|
|
// listening later is not handed old mail.
|
|
if (emailHooks.onNewEmailReceived.size === 0 || !client.getEmailChanges || !since) {
|
|
newMailBaselines.set(accountId, pushedState);
|
|
return;
|
|
}
|
|
if (since === pushedState) return;
|
|
const created: string[] = [];
|
|
let state = since;
|
|
for (let page = 0; page < NEW_MAIL_MAX_PAGES; page++) {
|
|
const delta = await client.getEmailChanges(state, undefined, EMAIL_DELTA_MAX_CHANGES);
|
|
if (!delta) {
|
|
// The baseline is too old for the server; start over from here.
|
|
newMailBaselines.set(accountId, pushedState);
|
|
return;
|
|
}
|
|
created.push(...delta.created);
|
|
state = delta.newState;
|
|
if (!delta.hasMoreChanges) break;
|
|
}
|
|
newMailBaselines.set(accountId, state);
|
|
const fresh = created.filter(id => !reportedNewMail.includes(id));
|
|
if (fresh.length === 0) return;
|
|
const inbox = get().mailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
|
|
if (!inbox) return;
|
|
const inboxIds = [inbox.id, inbox.originalId].filter((id): id is string => !!id);
|
|
const emails = await client.getSomeEmails(fresh);
|
|
for (const email of emails) {
|
|
if (email.keywords?.$draft || !inboxIds.some(id => email.mailboxIds?.[id])) continue;
|
|
reportedNewMail.push(email.id);
|
|
if (reportedNewMail.length > 500) reportedNewMail.shift();
|
|
void emailHooks.onNewEmailReceived.emit(emailSummary(email));
|
|
}
|
|
};
|
|
const next = newMailQueue.then(run, run).catch((error) => {
|
|
console.error('Failed to check for new mail:', error);
|
|
});
|
|
newMailQueue = next;
|
|
return next;
|
|
}
|
|
|
|
/** Let plugins change a search before it runs (onSearch); keeps what they return only when it is usable. */
|
|
async function transformSearch(query: string, filters: SearchFilters): Promise<{ query: string; filters: SearchFilters }> {
|
|
const out = await emailHooks.onSearch.transform({ query, filters: { ...filters } }) as { query?: unknown; filters?: unknown } | null;
|
|
return {
|
|
query: typeof out?.query === 'string' ? out.query : query,
|
|
filters: out?.filters && typeof out.filters === 'object'
|
|
? { ...DEFAULT_SEARCH_FILTERS, ...(out.filters as Partial<SearchFilters>) }
|
|
: filters,
|
|
};
|
|
}
|
|
|
|
/** Overlapping state-change runs share one syncing/idle cycle (onSyncStateChange). */
|
|
let syncRuns = 0;
|
|
|
|
function resolveActionClient(passedClient: IJMAPClient): IJMAPClient {
|
|
const viewingId = useEmailStore.getState().viewingAccountId;
|
|
if (!viewingId) return passedClient;
|
|
const c = useAuthStore.getState().getClientForAccount(viewingId);
|
|
return c ?? passedClient;
|
|
}
|
|
|
|
/**
|
|
* The client of the login a row is stamped with. Never another login's once
|
|
* that one is gone: its ids name other messages there.
|
|
*/
|
|
function stampedClient(clientAccountId: string): IJMAPClient {
|
|
const client = useAuthStore.getState().getClientForAccount(clientAccountId);
|
|
if (!client) throw new Error('Not connected');
|
|
return client;
|
|
}
|
|
|
|
/**
|
|
* Thrown by archive actions when no archive folder exists in the target
|
|
* account. Callers map it to the translated
|
|
* `email_viewer.archive_mailbox_not_found` toast; a silent return left the
|
|
* user with a shortcut/button that did nothing. (#578)
|
|
*/
|
|
export class ArchiveMailboxNotFoundError extends Error {
|
|
constructor() {
|
|
super('Archive mailbox not found - cannot archive email');
|
|
this.name = 'ArchiveMailboxNotFoundError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Puts a message whose send was cancelled back in Drafts, out of Sent, where
|
|
* the send filed it as soon as the submission was created.
|
|
*
|
|
* `accountId` is the account the message was sent from. Mail sent from a
|
|
* group or shared identity lives in that account, beside its submission, and
|
|
* goes back to that account's Drafts. Email ids repeat across accounts, so the
|
|
* user's own account may hold an unrelated message under the same id.
|
|
*/
|
|
async function restoreSentToDraft(client: IJMAPClient, known: Mailbox[], emailId: string, accountId?: string): Promise<void> {
|
|
const { draftsMailbox, sentMailbox } = await submissionMailboxes(client, known, accountId);
|
|
if (!draftsMailbox) return;
|
|
const draftsId = draftsMailbox.originalId || draftsMailbox.id;
|
|
const sentId = sentMailbox?.originalId || sentMailbox?.id;
|
|
if (accountId) {
|
|
await client.restoreEmailToDraft(emailId, draftsId, sentId, accountId);
|
|
} else {
|
|
await client.restoreEmailToDraft(emailId, draftsId, sentId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Archive folder for a single-email or batch archive.
|
|
*
|
|
* `accountId` pins the owning account (unified view). Otherwise the selected
|
|
* mailbox decides: a shared/group folder scopes the lookup to its owner, an own
|
|
* folder to non-shared mailboxes. The user's own Archive is listed first in the
|
|
* merged own+shared list, so an unscoped `find` would pair the shared owner's
|
|
* accountId with a foreign mailbox id and Stalwart rejects the move. (#889)
|
|
*/
|
|
export function findArchiveMailbox(
|
|
mailboxes: Mailbox[],
|
|
selectedMailboxId: string | null | undefined,
|
|
accountId?: string,
|
|
): Mailbox | undefined {
|
|
const viewMailbox = mailboxes.find(m => m.id === selectedMailboxId);
|
|
const scopeId = accountId ?? (viewMailbox?.isShared ? viewMailbox.accountId : undefined);
|
|
const isArchive = (m: Mailbox) => m.role === 'archive' || m.name.toLowerCase() === 'archive';
|
|
return mailboxes.find(m =>
|
|
isArchive(m) && (scopeId ? m.accountId === scopeId : !m.isShared)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Archive folder for the action, created when the account has none.
|
|
*
|
|
* Stalwart's default folder set has no archive-role mailbox, so on a fresh
|
|
* account the Archive button and shortcut only raised the not-found toast.
|
|
* The folder is created in the account the action targets (see
|
|
* findArchiveMailbox) with the `archive` role, so the sidebar shows it under
|
|
* its translated name and other clients recognise it. `refresh` reloads the
|
|
* mailbox list so the caller gets the store's own object (namespaced id,
|
|
* `originalId`) rather than the bare one Mailbox/set returned. (#578)
|
|
*/
|
|
export async function ensureArchiveMailbox(opts: {
|
|
client: IJMAPClient;
|
|
mailboxes: Mailbox[];
|
|
selectedMailboxId: string | null | undefined;
|
|
accountId?: string;
|
|
refresh: () => Promise<Mailbox[]>;
|
|
}): Promise<Mailbox | null> {
|
|
// The admin turned the Archive action off (#1257): never archive, never create the folder.
|
|
if (!isArchiveActionEnabled()) return null;
|
|
const existing = findArchiveMailbox(opts.mailboxes, opts.selectedMailboxId, opts.accountId);
|
|
if (existing) return existing;
|
|
|
|
const viewMailbox = opts.mailboxes.find(m => m.id === opts.selectedMailboxId);
|
|
const scopeId = opts.accountId ?? (viewMailbox?.isShared ? viewMailbox.accountId : undefined);
|
|
const mailCapability = opts.client.getAccountCapability?.('urn:ietf:params:jmap:mail', scopeId) as
|
|
{ mayCreateTopLevelMailbox?: boolean } | undefined;
|
|
// Only an explicit account-level denial prevents creating the root Archive.
|
|
// Existing Archives (and their year/month child folders) are unaffected.
|
|
if (mailCapability?.mayCreateTopLevelMailbox === false) throw new ArchiveMailboxNotFoundError();
|
|
// null: a plugin refused the new folder (and said why).
|
|
if (!(await createMailboxWithHooks(opts.client, 'Archive', undefined, scopeId, { role: 'archive' }))) return null;
|
|
|
|
const created = findArchiveMailbox(await opts.refresh(), opts.selectedMailboxId, opts.accountId);
|
|
if (!created) throw new ArchiveMailboxNotFoundError();
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* JMAP accountId for opening an email that carries no source stamps.
|
|
*
|
|
* Normally the selected folder decides: a shared/group folder's owner, else
|
|
* the account's own (undefined). During an unscoped ("All folders") search
|
|
* the hits come from the primary account even while a shared folder is
|
|
* selected, so deriving the owner from that folder asks the wrong account and
|
|
* `getEmail` returns nothing. In that case the caller's own account is used.
|
|
* (#923) Since #1082 the unscoped search stamps its hits with their source
|
|
* account (own or shared), so this only decides for the rare unstamped row,
|
|
* e.g. one a plugin added to the results.
|
|
*/
|
|
export function resolveUnstampedEmailAccountId(opts: {
|
|
mailboxes: Mailbox[];
|
|
selectedMailbox: string | null | undefined;
|
|
searchActive: boolean;
|
|
searchMailboxId: string;
|
|
}): string | undefined {
|
|
if (opts.searchActive && isAllFoldersSearchScope(opts.searchMailboxId)) return undefined;
|
|
const mailbox = opts.mailboxes.find(mb => mb.id === opts.selectedMailbox);
|
|
return mailbox?.isShared ? mailbox.accountId : undefined;
|
|
}
|
|
|
|
function mailboxesInView(state: Pick<EmailStore, 'viewingAccountId' | 'accountMailboxes' | 'mailboxes'>): Mailbox[] {
|
|
if (state.viewingAccountId) {
|
|
return state.accountMailboxes[state.viewingAccountId] ?? state.mailboxes;
|
|
}
|
|
return state.mailboxes;
|
|
}
|
|
|
|
function resolveActionMailboxes(): Mailbox[] {
|
|
return mailboxesInView(useEmailStore.getState());
|
|
}
|
|
|
|
/** Resolve blobs from the message's source, or the folder being browsed. */
|
|
/** One row of the message list: its own key and the keys selecting it selects. */
|
|
export interface ListRow {
|
|
key: string;
|
|
members: string[];
|
|
}
|
|
|
|
/** The row a key belongs to: the row it names, else the first row holding it. */
|
|
function findListRow(rows: ListRow[], key: string): number {
|
|
const own = rows.findIndex((row) => row.key === key);
|
|
return own !== -1 ? own : rows.findIndex((row) => row.members.includes(key));
|
|
}
|
|
|
|
export interface EmailBlobContext {
|
|
client: IJMAPClient | null;
|
|
accountId?: string;
|
|
clientAccountId?: string;
|
|
}
|
|
|
|
/** The login and JMAP account (undefined: the login's own) a message is in. */
|
|
export type EmailWriteSource = Pick<EmailBlobContext, 'clientAccountId' | 'accountId'>;
|
|
|
|
export function resolveEmailBlobContext(
|
|
email: Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'> | null,
|
|
passedClient: IJMAPClient | null,
|
|
): EmailBlobContext {
|
|
const state = useEmailStore.getState();
|
|
const auth = useAuthStore.getState();
|
|
const clientAccountId = email?.sourceClientAccountId ?? state.viewingAccountId ?? undefined;
|
|
// A stamped login must never fall back to another account: blob ids can collide.
|
|
const client = clientAccountId
|
|
? auth.getClientForAccount(clientAccountId) ?? null
|
|
: passedClient;
|
|
const accountId = email?.sourceAccountId ?? resolveUnstampedEmailAccountId({
|
|
mailboxes: mailboxesInView(state),
|
|
selectedMailbox: state.selectedMailbox,
|
|
searchActive: !!state.searchQuery || !isFilterEmpty(state.searchFilters),
|
|
searchMailboxId: state.searchMailboxId,
|
|
});
|
|
return {
|
|
client,
|
|
accountId,
|
|
clientAccountId: clientAccountId ?? (client === auth.client ? auth.activeAccountId ?? undefined : undefined),
|
|
};
|
|
}
|
|
|
|
/** The login and account of a row in the view on screen now (resolveEmailBlobContext). */
|
|
export function emailWriteSource(email: Pick<Email, 'sourceClientAccountId' | 'sourceAccountId'>, passedClient: IJMAPClient | null): EmailWriteSource {
|
|
const { clientAccountId, accountId } = resolveEmailBlobContext(email, passedClient);
|
|
return { clientAccountId, accountId };
|
|
}
|
|
|
|
// List requests can finish after navigation. Never apply an old folder/tag
|
|
// response (or error) to the view the user has since selected.
|
|
/**
|
|
* The plain folder list on screen and the key it is remembered under (see
|
|
* lib/folder-list-cache). Null while the rows showing belong to no folder
|
|
* list - a search, a tag, a unified view, or a folder still loading.
|
|
*/
|
|
let shownFolderList: { key: string; mailboxId: string; pageSize: number } | null = null;
|
|
|
|
/**
|
|
* The initial data load login() started for this client (prefetchInitialData):
|
|
* the promise while it runs, 'done' (once) after it loaded the mailboxes and a page.
|
|
* The mail view's own bootstrap joins or skips it rather than sending the
|
|
* same Mailbox/get, folder page and tag counts a second time.
|
|
*/
|
|
export function pendingInitialData(client: IJMAPClient): Promise<void> | 'done' | undefined {
|
|
const target = client as IJMAPClient & { __prefetchPromise?: Promise<void>; __initialDataLoaded?: boolean };
|
|
if (target.__prefetchPromise) return target.__prefetchPromise;
|
|
// Good for the one bootstrap that follows the login; any later one (an
|
|
// account switch back to this client) refreshes as before.
|
|
if (!target.__initialDataLoaded) return undefined;
|
|
delete target.__initialDataLoaded;
|
|
return 'done';
|
|
}
|
|
|
|
/** What a folder's list is remembered as (see lib/folder-list-cache). */
|
|
function folderListState(s: EmailStore): FolderList {
|
|
return {
|
|
emails: s.emails,
|
|
totalEmails: s.totalEmails,
|
|
hasMoreEmails: s.hasMoreEmails,
|
|
sync: s.emailListSync,
|
|
threadEmailCounts: s.threadEmailCounts,
|
|
};
|
|
}
|
|
|
|
/** The list row beginOpenEmail put in the viewer, until its message arrives. */
|
|
let openingRow: Email | null = null;
|
|
|
|
// Pending delayed "mark thread read" timers, by thread key. Collapsing the
|
|
// thread, or opening a message outside it, cancels its timer.
|
|
const pendingThreadReads = new Map<string, () => void>();
|
|
|
|
function cancelPendingThreadRead(threadKey?: string): void {
|
|
const keys = threadKey === undefined ? [...pendingThreadReads.keys()] : [threadKey];
|
|
for (const key of keys) {
|
|
pendingThreadReads.get(key)?.();
|
|
pendingThreadReads.delete(key);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The per-thread message counts to store once a page arrived: the counts that
|
|
* page brought along (Thread/get chained onto its query) under the keys the
|
|
* list groups threads by, merged over `current`. Empty when it brought none,
|
|
* so the caller knows to ask for them separately.
|
|
*/
|
|
function threadCountsPatch(
|
|
emails: Email[],
|
|
counts: Record<string, number> | undefined,
|
|
current: Map<string, number>,
|
|
): { threadEmailCounts: Map<string, number> } | Record<string, never> {
|
|
if (!counts) return {};
|
|
const merged = new Map(current);
|
|
for (const email of emails) {
|
|
if (!email.threadId) continue;
|
|
const count = counts[email.threadId];
|
|
if (count !== undefined) merged.set(threadKeyFor(email), count);
|
|
}
|
|
return { threadEmailCounts: merged };
|
|
}
|
|
|
|
function captureEmailListView(): () => boolean {
|
|
const view = useEmailStore.getState();
|
|
const activeAccountId = useAuthStore.getState().activeAccountId;
|
|
const epoch = currentStoreEpoch();
|
|
return () => {
|
|
const current = useEmailStore.getState();
|
|
return currentStoreEpoch() === epoch
|
|
&& current.selectedMailbox === view.selectedMailbox
|
|
&& current.selectedKeyword === view.selectedKeyword
|
|
&& current.viewingAccountId === view.viewingAccountId
|
|
&& current.isUnifiedView === view.isUnifiedView
|
|
&& current.unifiedRole === view.unifiedRole
|
|
&& current.crossView === view.crossView
|
|
&& useAuthStore.getState().activeAccountId === activeAccountId;
|
|
};
|
|
}
|
|
|
|
// fetchUnifiedEmails/fetchCrossView start a new view generation of their own,
|
|
// so they cannot tell that the user left while the caller was still awaiting
|
|
// something (e.g. the unified account list). Such callers capture this before
|
|
// the await and skip the fetch if it reports false; otherwise the unified view
|
|
// is switched back on over the folder the user picked meanwhile (#1102).
|
|
export function captureViewToken(): () => boolean {
|
|
const token = useEmailStore.getState().viewToken;
|
|
return () => useEmailStore.getState().viewToken === token;
|
|
}
|
|
|
|
/**
|
|
* The owner JMAP accountId of the shared/group folder currently being viewed
|
|
* directly (the "Shared" sidebar section, non-unified), or `undefined` for a
|
|
* normal own-account view. Emails in this view are undecorated (no
|
|
* `sourceAccountId`) and are reached through the ACTIVE client, but their
|
|
* mutations must carry this accountId — otherwise JMAP `Email/set` is sent to
|
|
* the user's own account, where it silently no-ops (Stalwart returns the ids as
|
|
* `updated: null` with an unchanged state, not `notUpdated`) and the change is
|
|
* lost on the next reload. Mirrors `fetchEmails` and the single-email path.
|
|
*/
|
|
function resolveViewAccountId(): string | undefined {
|
|
const state = useEmailStore.getState();
|
|
const mb = resolveActionMailboxes().find(m => m.id === state.selectedMailbox);
|
|
return mb?.isShared ? mb.accountId : undefined;
|
|
}
|
|
|
|
/**
|
|
* Resolve a store-side mailbox id to the JMAP mutation target.
|
|
*
|
|
* Shared/group mailboxes use namespaced ids in the store (`accountId:id`) so
|
|
* ids from different accounts cannot collide. JMAP `Mailbox/set`, however,
|
|
* needs the owner's bare mailbox id plus the owner's accountId. The shared
|
|
* account is still reached through the active/viewing login client; it does
|
|
* not have a separately connected client of its own.
|
|
*
|
|
* If the owner also has a directly connected login on the same server, use that
|
|
* client's primary account. Otherwise (the usual delegated/group case), keep
|
|
* the active/viewing client and pass the owner's accountId explicitly.
|
|
*/
|
|
function resolveMailboxMutationContext(
|
|
passedClient: IJMAPClient,
|
|
storeMailboxId: string,
|
|
): {
|
|
client: IJMAPClient;
|
|
mailboxId: string;
|
|
accountId: string | undefined;
|
|
ownerAccountId: string | undefined;
|
|
} {
|
|
const state = useEmailStore.getState();
|
|
const actionMailboxes = resolveActionMailboxes();
|
|
const mailbox = actionMailboxes.find((mb) => mb.id === storeMailboxId)
|
|
?? state.mailboxes.find((mb) => mb.id === storeMailboxId)
|
|
?? Object.values(state.accountMailboxes).flat().find((mb) => mb.id === storeMailboxId);
|
|
|
|
// No live mailbox object (a concurrent refresh dropped the shared account
|
|
// while a rename/create prompt was open, or a caller passed a stale id):
|
|
// recover the owner and the bare id from the namespaced store id itself.
|
|
// Without this the raw `accountId:id` would be sent as a mailbox id AND the
|
|
// owner would be lost, so the mutation - including the role-clearing sweep
|
|
// in setMailboxRole - would be applied to the active personal account.
|
|
const parsed = mailbox ? undefined : parseNamespacedMailboxId(storeMailboxId);
|
|
|
|
const ownerAccountId = mailbox?.accountId ?? parsed?.accountId;
|
|
const accountTarget = resolveMailboxAccountTarget(passedClient, ownerAccountId);
|
|
|
|
return {
|
|
...accountTarget,
|
|
mailboxId: mailbox?.originalId || parsed?.mailboxId || storeMailboxId,
|
|
ownerAccountId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Split a namespaced shared-mailbox store id (`${accountId}:${mailboxId}`)
|
|
* back into its parts. Bare (personal) ids carry no separator and yield
|
|
* `undefined`, which keeps them on the personal path.
|
|
*/
|
|
function parseNamespacedMailboxId(
|
|
storeMailboxId: string,
|
|
): { accountId: string; mailboxId: string } | undefined {
|
|
const separator = storeMailboxId.indexOf(':');
|
|
if (separator <= 0 || separator === storeMailboxId.length - 1) return undefined;
|
|
return {
|
|
accountId: storeMailboxId.slice(0, separator),
|
|
mailboxId: storeMailboxId.slice(separator + 1),
|
|
};
|
|
}
|
|
|
|
function resolveMailboxAccountTarget(
|
|
passedClient: IJMAPClient,
|
|
ownerAccountId?: string,
|
|
): { client: IJMAPClient; accountId: string | undefined } {
|
|
const client = resolveActionClient(passedClient);
|
|
if (!ownerAccountId || client.getAccountId() === ownerAccountId) {
|
|
return { client, accountId: undefined };
|
|
}
|
|
|
|
// A directly connected owner login can act on its own primary account. Match
|
|
// it on server URL as well as account id: JMAP account ids are opaque
|
|
// per-server values, so two logins on different servers can share one id and
|
|
// matching on the id alone would route the mutation to a foreign server -
|
|
// where that bare mailbox id may well exist and belong to someone else.
|
|
const serverUrl = client.getServerUrl();
|
|
for (const candidate of useAuthStore.getState().getAllConnectedClients().values()) {
|
|
if (candidate.getAccountId() === ownerAccountId && candidate.getServerUrl() === serverUrl) {
|
|
return { client: candidate, accountId: undefined };
|
|
}
|
|
}
|
|
|
|
// The usual delegated/group case: reach the owner through the active client.
|
|
return { client, accountId: ownerAccountId };
|
|
}
|
|
|
|
/**
|
|
* Resolves the store-side mailbox ids that make up a personal account's
|
|
* contribution to the unified cross views (All mail / Unread / Starred), honoring
|
|
* that account's `allMailFolderIds` folder selection. Returns `undefined` when the
|
|
* account has no explicit selection, so the cross views fall back to their
|
|
* role-exclusion default (inbox + custom folders). An explicit `[]` selection
|
|
* yields an empty list (no own folders). `ownMailboxes` must already exclude
|
|
* shared folders - the picker only ever scopes the user's own folders.
|
|
*/
|
|
function resolveCrossIncludedMailboxIds(accountId: string, ownMailboxes: Mailbox[]): string[] | undefined {
|
|
const configured = useSettingsStore.getState().allMailFolderIds[accountId];
|
|
if (configured === undefined) return undefined;
|
|
const selected = new Set(configured);
|
|
return ownMailboxes.filter((mb) => selected.has(mb.id)).map((mb) => mb.id);
|
|
}
|
|
|
|
/**
|
|
* Resolves the JMAP client, mailbox list, and JMAP accountId to use for a
|
|
* single-email action.
|
|
*
|
|
* In aggregate views each email is decorated with its source reference:
|
|
* `sourceClientAccountId` (the logged-in client it is reachable through) and
|
|
* `sourceAccountId` (the owning JMAP account). The mutation must be routed to
|
|
* that client/account, or it is sent to the active account whose server doesn't
|
|
* know the id, so JMAP `Email/set` silently returns `notUpdated` and the change
|
|
* is lost on the next reload (issue #281). We always pass `sourceAccountId` as
|
|
* the JMAP accountId: for personal sources it equals the client's primary (a
|
|
* no-op, no namespacing), for shared/group sources it targets the owner. The
|
|
* owner's mailbox list (cached by `buildUnifiedAccountClients` under the login
|
|
* and that JMAP id, see sourceMailboxes) resolves role-based destinations like
|
|
* trash/archive.
|
|
*
|
|
* For the normal single-account / viewing-account flow this preserves the
|
|
* existing behavior exactly: the active/viewing client, its mailbox list, and
|
|
* the shared-mailbox accountId derived from the currently selected mailbox.
|
|
*/
|
|
export function resolveEmailActionContext(
|
|
email: { sourceClientAccountId?: string; sourceAccountId?: string },
|
|
passedClient: IJMAPClient,
|
|
): { client: IJMAPClient; mailboxes: Mailbox[]; accountId: string | undefined } {
|
|
const state = useEmailStore.getState();
|
|
// In aggregate views every email is decorated with its source reference:
|
|
// `sourceClientAccountId` (the login client it is reachable through) and
|
|
// `sourceAccountId` (the owning JMAP account). These are unambiguous across
|
|
// personal and shared/group sources, so resolution is the same three lines for
|
|
// both - no id-space guessing, no capability scan. For personal sources
|
|
// `sourceAccountId` equals the client's primary, so passing it to JMAP is a
|
|
// no-op (matches the previous `accountId: undefined` behavior exactly).
|
|
// The stamps are honored whenever present, not only while `isUnifiedView`
|
|
// is set: a stamped email (e.g. the open selection) can outlive the flag, and
|
|
// bare ids collide across accounts, so the active client is never a safe
|
|
// substitute for the stamped one, also not once that login is gone. (#847)
|
|
if (email.sourceClientAccountId && email.sourceAccountId) {
|
|
return {
|
|
client: stampedClient(email.sourceClientAccountId),
|
|
mailboxes: sourceMailboxes(state, email.sourceClientAccountId, email.sourceAccountId),
|
|
accountId: email.sourceAccountId,
|
|
};
|
|
}
|
|
const mailboxes = resolveActionMailboxes();
|
|
const currentMailbox = mailboxes.find((mb) => mb.id === state.selectedMailbox);
|
|
return {
|
|
client: resolveActionClient(passedClient),
|
|
mailboxes,
|
|
accountId: currentMailbox?.isShared ? currentMailbox.accountId : undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolution for a keyword write, by email id.
|
|
*
|
|
* Same routing as `resolveEmailActionContext`, but only when the email is
|
|
* actually known to the store. A keyword write can be triggered for a message
|
|
* that is NOT the current selection - a Pro tab fetches its own email and does
|
|
* not go through `selectedEmail` - and for those the selected mailbox says
|
|
* nothing about the message's owner. Guessing from it would send an own-account
|
|
* write to a shared account whenever a shared folder happened to be open, so an
|
|
* unknown email keeps the previous behaviour instead: no explicit accountId,
|
|
* i.e. the reaching client's own account.
|
|
*/
|
|
export function resolveKeywordActionContext(
|
|
emailId: string,
|
|
passedClient: IJMAPClient,
|
|
emailKey?: string,
|
|
): { client: IJMAPClient; accountId: string | undefined } {
|
|
const state = useEmailStore.getState();
|
|
// By the row's key when given: in a merged view the bare id also matches
|
|
// another account's namesake, whose account would receive the write.
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
const email = state.emails.find(isTarget)
|
|
?? (state.selectedEmail && isTarget(state.selectedEmail) ? state.selectedEmail : undefined);
|
|
if (!email) return { client: passedClient, accountId: undefined };
|
|
const { client, accountId } = resolveEmailActionContext(email, passedClient);
|
|
return { client, accountId };
|
|
}
|
|
|
|
/**
|
|
* Local account id ("user@host") whose connected client owns `mailbox`.
|
|
* `mailbox.accountId` is the JMAP server's opaque id; map it back to a local
|
|
* client id, falling back to the viewing/active account — a delegated/shared
|
|
* folder has no separately-connected client, it's reached through the viewer's.
|
|
* Mirrors resolveDestAccountId in use-mailbox-drop.ts.
|
|
*/
|
|
function resolveDestLocalAccountId(mailbox: Mailbox): string | null {
|
|
const jmapId = mailbox.accountId;
|
|
if (jmapId) {
|
|
for (const [localId, client] of useAuthStore.getState().getAllConnectedClients()) {
|
|
if (client.getAccountId() === jmapId) return localId;
|
|
}
|
|
}
|
|
return useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* When `scopeToClientAccountId` is set, only the matching logged-in account
|
|
* (and the shared owners reachable through its client) is built - this keeps
|
|
* the unified mailbox within a single account boundary. Omitting it spans every
|
|
* logged-in account (the cross-account sub-option).
|
|
*
|
|
* Personal entries carry `crossIncludedMailboxIds` derived from the account's
|
|
* `allMailFolderIds` folder selection, restricting the All mail / Unread /
|
|
* Starred cross views to the chosen own folders (shared entries are left
|
|
* unrestricted so all their folders are included).
|
|
*
|
|
* A folder reachable through several logins (owner + grantee, or two grantees)
|
|
* is kept only once - see `dedupeUnifiedAccounts`.
|
|
*/
|
|
/**
|
|
* Folder lists of the logins in the unified scope, shared between builds.
|
|
*
|
|
* The scope is rebuilt on every connection while a browser restores its
|
|
* logins, on every background push and on every unified browse, load-more and
|
|
* search - and each build used to ask every login for its folders again, one
|
|
* login after the other. With ten logins that was ten full `Mailbox/get`s per
|
|
* build and over a hundred in the first seconds. A list is now fetched once,
|
|
* shared by every build that wants it while in flight, and reused until that
|
|
* login reports a change (see {@link invalidateUnifiedMailboxes}, called from
|
|
* the push handlers). The time limit is a backstop for a change whose push was
|
|
* lost, not the freshness mechanism.
|
|
*/
|
|
const UNIFIED_MAILBOX_TTL_MS = 60_000;
|
|
let unifiedMailboxGeneration = 0;
|
|
const unifiedMailboxCache = new WeakMap<object, Map<boolean, { at: number; generation: number; promise: Promise<Mailbox[]> }>>();
|
|
|
|
/**
|
|
* `fresh` is true only for the build that started the fetch. Only that build
|
|
* publishes the list into `accountMailboxes`: a reused one may be older than
|
|
* what the store holds now - optimistic updates land there directly - and
|
|
* writing it back would snap a just-changed counter back to its old value.
|
|
*/
|
|
async function unifiedMailboxesFor(
|
|
client: IJMAPClient,
|
|
includeGroup: boolean,
|
|
): Promise<{ mailboxes: Mailbox[]; fresh: boolean }> {
|
|
let perClient = unifiedMailboxCache.get(client);
|
|
if (!perClient) {
|
|
perClient = new Map();
|
|
unifiedMailboxCache.set(client, perClient);
|
|
}
|
|
const hit = perClient.get(includeGroup);
|
|
if (hit && hit.generation === unifiedMailboxGeneration && Date.now() - hit.at < UNIFIED_MAILBOX_TTL_MS) {
|
|
return { mailboxes: await hit.promise, fresh: false };
|
|
}
|
|
const entry = {
|
|
at: Date.now(),
|
|
generation: unifiedMailboxGeneration,
|
|
promise: includeGroup ? client.getAllMailboxes() : client.getMailboxes(),
|
|
};
|
|
perClient.set(includeGroup, entry);
|
|
const slot = perClient;
|
|
entry.promise.catch(() => {
|
|
if (slot.get(includeGroup) === entry) slot.delete(includeGroup);
|
|
});
|
|
return { mailboxes: await entry.promise, fresh: true };
|
|
}
|
|
|
|
/**
|
|
* Puts one login's folder list into `accountMailboxes` through the same cache
|
|
* the unified scope uses, so the two never fetch the same list twice and a
|
|
* list already there is not fetched again until that login reports a change.
|
|
*/
|
|
export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise<boolean> {
|
|
try {
|
|
const { mailboxes, fresh } = await unifiedMailboxesFor(client, false);
|
|
if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return true;
|
|
useEmailStore.setState((state) => ({
|
|
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes.slice() },
|
|
}));
|
|
return true;
|
|
} catch (error) {
|
|
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Forgets the folder lists cached for the unified scope: one login's, when it
|
|
* reports a change, or every login's when called without an argument.
|
|
*/
|
|
export function invalidateUnifiedMailboxes(client?: object): void {
|
|
if (client) unifiedMailboxCache.delete(client);
|
|
else unifiedMailboxGeneration++;
|
|
}
|
|
|
|
export async function buildUnifiedAccountClients(
|
|
opts: { includeGroup?: boolean; scopeToClientAccountId?: string } = {},
|
|
): Promise<UnifiedAccountClient[]> {
|
|
const { includeGroup = false, scopeToClientAccountId } = opts;
|
|
const authAccounts = useAccountStore.getState().accounts.filter(
|
|
(a) => a.isConnected && (!scopeToClientAccountId || a.id === scopeToClientAccountId),
|
|
);
|
|
const allClients = useAuthStore.getState().getAllConnectedClients();
|
|
const built: UnifiedAccountClient[] = [];
|
|
// Per-account mailbox lists gathered here are cached into `accountMailboxes`
|
|
// after the fan-out so single-email actions can resolve role-based
|
|
// destinations (trash/archive) in the email's own account (issue #281).
|
|
const fetchedMailboxes: Record<string, Mailbox[]> = {};
|
|
const ownLoginJmapIds = new Set<string>();
|
|
for (const a of authAccounts) {
|
|
const c = allClients.get(a.id);
|
|
if (c) ownLoginJmapIds.add(`${unifiedServerKey(c)} ${c.getAccountId()}`);
|
|
}
|
|
// Every login at once rather than one after the other: the slowest login,
|
|
// not the sum of all of them, bounds how long the scope takes.
|
|
const fetched = await Promise.allSettled(authAccounts.map(async (a) => {
|
|
const c = allClients.get(a.id);
|
|
if (!c) return null;
|
|
return { a, c, ...(await unifiedMailboxesFor(c, includeGroup)) };
|
|
}));
|
|
for (const result of fetched) {
|
|
// Skip the account on mailbox fetch failure.
|
|
if (result.status !== 'fulfilled' || !result.value) continue;
|
|
const { a, c, mailboxes, fresh } = result.value;
|
|
// A reused list never overwrites a live one, but fills a key nothing has published yet.
|
|
const publish = (key: string, list: Mailbox[]) => {
|
|
if (fresh || !useEmailStore.getState().accountMailboxes[key]) fetchedMailboxes[key] = list;
|
|
};
|
|
try {
|
|
const ownMailboxes = includeGroup
|
|
? mailboxes.filter((m) => !m.isShared)
|
|
: mailboxes.slice();
|
|
// Primary JMAP account id of this login. Stamped onto personal emails as
|
|
// `sourceAccountId`; equals the client's primary so passing it to JMAP is a
|
|
// no-op (no namespacing) — keeps personal behavior identical while making
|
|
// resolution branch-free against shared sources.
|
|
const primaryJmapId = c.getAccountId();
|
|
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false, crossIncludedMailboxIds: resolveCrossIncludedMailboxIds(a.id, ownMailboxes) });
|
|
publish(a.id, ownMailboxes);
|
|
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
|
|
// resolves uniformly for personal and shared sources alike, and under
|
|
// login plus JMAP id, which stays apart when another login's server uses
|
|
// the same account id (sourceMailboxes).
|
|
publish(primaryJmapId, ownMailboxes);
|
|
publish(sourceMailboxesKey(a.id, primaryJmapId), ownMailboxes);
|
|
|
|
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,
|
|
clientAccountId: a.id,
|
|
jmapAccountId: ownerId,
|
|
isShared: true,
|
|
});
|
|
// Cache the owner's mailbox list keyed by its JMAP id so single-email
|
|
// and batch actions can resolve role-based destinations (trash/archive)
|
|
// in the owner account instead of falling back to the active account.
|
|
// When the owner is logged in too, its own (un-namespaced) list wins:
|
|
// dedupeUnifiedAccounts routes that folder through the owner's login.
|
|
if (!ownLoginJmapIds.has(`${unifiedServerKey(c)} ${ownerId}`)) publish(ownerId, ownerMailboxes);
|
|
publish(sourceMailboxesKey(a.id, ownerId), ownerMailboxes);
|
|
}
|
|
}
|
|
} catch {
|
|
/* skip account on mailbox fetch failure */
|
|
}
|
|
}
|
|
if (Object.keys(fetchedMailboxes).length > 0) {
|
|
useEmailStore.setState((state) => ({
|
|
accountMailboxes: { ...state.accountMailboxes, ...fetchedMailboxes },
|
|
}));
|
|
}
|
|
return dedupeUnifiedAccounts(built);
|
|
}
|
|
|
|
/**
|
|
* The accounts a tag view spans: the active/viewing login's own account plus
|
|
* every group/shared owner whose folders that login can see (#1038).
|
|
*
|
|
* A tag keyword is set on messages in all of them, so the sidebar tag entry
|
|
* fans out over the whole set instead of only the account of the folder that
|
|
* happened to be selected. Built synchronously from the mailbox list the
|
|
* sidebar already holds (own + delegated folders), so no extra round trip.
|
|
* Every entry reaches the server through the same login client; shared
|
|
* entries are flagged so JMAP requests target the owner's accountId.
|
|
*/
|
|
export function buildTagViewAccountClients(passedClient: IJMAPClient): UnifiedAccountClient[] {
|
|
const client = resolveActionClient(passedClient);
|
|
const mailboxes = resolveActionMailboxes();
|
|
const state = useEmailStore.getState();
|
|
const auth = useAuthStore.getState();
|
|
// AccountEntry.id of the login this client belongs to (`getClientForAccount`
|
|
// key), stamped as `sourceClientAccountId` so actions resolve the client.
|
|
let clientAccountId: string | undefined;
|
|
for (const [id, c] of auth.getAllConnectedClients()) {
|
|
if (c === client) { clientAccountId = id; break; }
|
|
}
|
|
clientAccountId ??= state.viewingAccountId ?? auth.activeAccountId ?? client.getAccountId();
|
|
const primaryJmapId = client.getAccountId();
|
|
const account = useAccountStore.getState().getAccountById(clientAccountId);
|
|
|
|
const own = mailboxes.filter((m) => !m.isShared);
|
|
const built: UnifiedAccountClient[] = [{
|
|
accountId: clientAccountId,
|
|
accountLabel: account?.label || account?.email || primaryJmapId,
|
|
client,
|
|
mailboxes: own,
|
|
clientAccountId,
|
|
jmapAccountId: primaryJmapId,
|
|
isShared: false,
|
|
}];
|
|
|
|
const sharedByOwner = new Map<string, Mailbox[]>();
|
|
for (const m of mailboxes) {
|
|
if (!m.isShared || !m.accountId || m.accountId === primaryJmapId) continue;
|
|
const list = sharedByOwner.get(m.accountId) ?? [];
|
|
list.push(m);
|
|
sharedByOwner.set(m.accountId, list);
|
|
}
|
|
for (const [ownerId, ownerMailboxes] of sharedByOwner) {
|
|
built.push({
|
|
accountId: ownerId,
|
|
accountLabel: ownerMailboxes.find((m) => m.accountName)?.accountName || ownerId,
|
|
client,
|
|
mailboxes: ownerMailboxes,
|
|
clientAccountId,
|
|
jmapAccountId: ownerId,
|
|
isShared: true,
|
|
});
|
|
}
|
|
return built;
|
|
}
|
|
|
|
/**
|
|
* Whether a search is running under a folder-less scope (the default "All
|
|
* folders except Spam and Trash" or "All folders", see
|
|
* isAllFoldersSearchScope) of the standard (non-unified) list. Those scopes
|
|
* span the folders of the own account AND of the group/shared accounts this
|
|
* login reaches, so the search fans out like a tag view and stamps its hits
|
|
* with their source account (#1082). A search scoped to one folder stays
|
|
* single-account.
|
|
*/
|
|
export function isUnscopedSearchActive(
|
|
s: Pick<EmailStore, 'isUnifiedView' | 'searchMailboxId' | 'searchQuery' | 'searchFilters'>,
|
|
): boolean {
|
|
return !s.isUnifiedView && isAllFoldersSearchScope(s.searchMailboxId) && (!!s.searchQuery || !isFilterEmpty(s.searchFilters));
|
|
}
|
|
|
|
/**
|
|
* The fan-out options of a folder-less search scope: only "All folders"
|
|
* searches Trash and Junk, the default scope ("") leaves them out.
|
|
*/
|
|
/** A single-account search result with its deferred snippets in fan-out shape. */
|
|
function withSnippetBatches(
|
|
result: SearchEmailsResult,
|
|
): Omit<SearchEmailsResult, 'snippets'> & { snippets?: Promise<DeferredSnippetBatch[]> } {
|
|
const { snippets, ...rest } = result;
|
|
return snippets ? { ...rest, snippets: snippets.then((list) => (list ? [{ list }] : [])) } : rest;
|
|
}
|
|
|
|
/**
|
|
* Highlights the results of an interactive search once its deferred
|
|
* SearchSnippet/get lands (see SearchRequestOptions.deferSnippets). The list
|
|
* is on screen by then, so this applies only while that same search still is:
|
|
* matching ids would not do, since a folder opened in the meantime can hold
|
|
* the same messages and must not show search excerpts.
|
|
*/
|
|
function highlightWhenSnippetsArrive(snippets: Promise<DeferredSnippetBatch[]> | undefined): void {
|
|
if (!snippets) return;
|
|
const { searchQuery, searchFilters, searchMailboxId } = useEmailStore.getState();
|
|
const isSameView = captureEmailListView();
|
|
void snippets.then((batches) => {
|
|
const state = useEmailStore.getState();
|
|
if (!isSameView() || state.isLoading) return;
|
|
if (state.searchQuery !== searchQuery || state.searchFilters !== searchFilters || state.searchMailboxId !== searchMailboxId) return;
|
|
const emails = withSearchSnippets(state.emails, batches);
|
|
if (emails !== state.emails) useEmailStore.setState({ emails });
|
|
});
|
|
}
|
|
|
|
function acrossAccountsSearchOptions(searchMailboxId: string): AcrossAccountsSearchOptions {
|
|
return { excludeTrashAndJunk: searchMailboxId !== SEARCH_SCOPE_ALL_FOLDERS };
|
|
}
|
|
|
|
/**
|
|
* Whether the current list mixes messages from several accounts, so actions
|
|
* must route by each email's source stamps rather than by the selected
|
|
* folder: the unified / cross-account views, a tag view (#1038) and an
|
|
* "All folders" search (#1082).
|
|
*/
|
|
export function isAggregateListView(): boolean {
|
|
const s = useEmailStore.getState();
|
|
return s.isUnifiedView || !!s.selectedKeyword || isUnscopedSearchActive(s);
|
|
}
|
|
|
|
/** A batch write's share for one source of an aggregate list (see groupBySource). */
|
|
interface SourceBatch<T extends SourceStamped = SourceStamped> {
|
|
/** Login that reaches the messages (`sourceClientAccountId`). */
|
|
clientAccountId?: string;
|
|
/** JMAP account that owns them; undefined for undecorated messages. */
|
|
accountId?: string;
|
|
ids: string[];
|
|
messages: T[];
|
|
}
|
|
|
|
type SourceStamped = { id: string; sourceClientAccountId?: string; sourceAccountId?: string };
|
|
|
|
/** The login and account a message was stamped with; '' for an undecorated one. */
|
|
function sourceKey(message: { sourceClientAccountId?: string; sourceAccountId?: string }): string {
|
|
return message.sourceAccountId ? `${message.sourceClientAccountId ?? ''}|${message.sourceAccountId}` : '';
|
|
}
|
|
|
|
/**
|
|
* One row of an aggregate list: its source and its id. The id alone can name
|
|
* rows of several accounts, since ids repeat across accounts and servers.
|
|
*/
|
|
function sourceRowKey(message: SourceStamped): string {
|
|
return `${sourceKey(message)}#${message.id}`;
|
|
}
|
|
|
|
/**
|
|
* Whether the open message is one of the rows `rowKeys` names (sourceRowKey).
|
|
* A copy without the list's source stamps goes by its id.
|
|
*/
|
|
function isOneOfRows(email: SourceStamped | null | undefined, rowKeys: Set<string>): boolean {
|
|
if (!email) return false;
|
|
if (email.sourceAccountId) return rowKeys.has(sourceRowKey(email));
|
|
return [...rowKeys].some(key => key.endsWith(`#${email.id}`));
|
|
}
|
|
|
|
/**
|
|
* Groups the messages of a batch write by the login that reaches each one and
|
|
* the JMAP account that owns it. Account ids are only unique per server: two
|
|
* logins on different servers often share them (Stalwart numbers accounts b,
|
|
* c, d, ... on every install), and a group keyed on the account alone sent one
|
|
* login's messages through the other's client - to a server where the same
|
|
* message ids name other messages. Messages without source stamps (a plain
|
|
* folder view) form one group without an account, for the caller's client.
|
|
*/
|
|
function groupBySource<T extends SourceStamped>(messages: T[]): SourceBatch<T>[] {
|
|
const groups = new Map<string, SourceBatch<T>>();
|
|
for (const message of messages) {
|
|
const accountId = message.sourceAccountId || undefined;
|
|
const clientAccountId = accountId ? message.sourceClientAccountId : undefined;
|
|
const key = sourceKey(message);
|
|
const group = groups.get(key) ?? { clientAccountId, accountId, ids: [], messages: [] };
|
|
group.ids.push(message.id);
|
|
group.messages.push(message);
|
|
groups.set(key, group);
|
|
}
|
|
return [...groups.values()];
|
|
}
|
|
|
|
/** Messages of a batch write that were left as they were, and why. */
|
|
type BatchFailure = { ids: string[]; error: unknown };
|
|
|
|
/**
|
|
* What a batch write reports when some of its groups failed: the reason when
|
|
* one group failed, else how many messages were left as they were and the
|
|
* first reason.
|
|
*/
|
|
function batchFailureMessage(failures: BatchFailure[], action: string): string {
|
|
const reason = (error: unknown) => (error instanceof Error && error.message ? error.message : `Failed to ${action}`);
|
|
if (failures.length === 1) return reason(failures[0].error);
|
|
const count = failures.reduce((sum, failure) => sum + failure.ids.length, 0);
|
|
return `Failed to ${action} ${count} emails; first error: ${reason(failures[0].error)}`;
|
|
}
|
|
|
|
/** Why a selected row that resolveSelectionRows found nowhere was left alone. */
|
|
const NOT_IN_LIST = 'Message not found in the list';
|
|
|
|
/** The failure for selected rows resolveSelectionRows found nowhere; none without any. */
|
|
function notFoundFailures(ids: string[]): BatchFailure[] {
|
|
return ids.length > 0 ? [{ ids, error: new Error(NOT_IN_LIST) }] : [];
|
|
}
|
|
|
|
/**
|
|
* The client for a SourceBatch: the stamped login's, or the caller's for
|
|
* undecorated messages. Undefined when the login is gone - never another
|
|
* login's client, which would write to the wrong server.
|
|
*/
|
|
function sourceBatchClient(batch: SourceBatch, passedClient: IJMAPClient): IJMAPClient | undefined {
|
|
if (!batch.accountId) return resolveActionClient(passedClient);
|
|
return batch.clientAccountId ? useAuthStore.getState().getClientForAccount(batch.clientAccountId) : undefined;
|
|
}
|
|
|
|
/**
|
|
* Key of the folder list of one source of an aggregate list in
|
|
* `accountMailboxes`: the login plus the JMAP account. The list is cached
|
|
* under the bare JMAP id as well, but with two logins that share account ids
|
|
* that entry holds whichever login's list was fetched last.
|
|
*/
|
|
function sourceMailboxesKey(clientAccountId: string, jmapAccountId: string): string {
|
|
return `${clientAccountId}|${jmapAccountId}`;
|
|
}
|
|
|
|
/**
|
|
* The folders a source's role-based destinations (Archive, Junk, Inbox,
|
|
* Deleted Items) come from: the list cached for its login and account, else
|
|
* that login's folder list narrowed to the account: a tag view and an "All
|
|
* folders" search stamp their rows too, but only the unified views fill that
|
|
* cache. Never the list cached under the bare account id, which holds
|
|
* whichever login's list was fetched last when two logins share account ids.
|
|
*/
|
|
function sourceMailboxes(
|
|
state: Pick<EmailStore, 'accountMailboxes' | 'mailboxes'>,
|
|
clientAccountId: string,
|
|
jmapAccountId: string,
|
|
): Mailbox[] {
|
|
const cached = state.accountMailboxes[sourceMailboxesKey(clientAccountId, jmapAccountId)];
|
|
if (cached) return cached;
|
|
const loginMailboxes = clientAccountId === useAuthStore.getState().activeAccountId
|
|
? state.mailboxes
|
|
: state.accountMailboxes[clientAccountId];
|
|
return (loginMailboxes ?? []).filter((mailbox) => mailbox.accountId === jmapAccountId);
|
|
}
|
|
|
|
/** sourceMailboxes of a SourceBatch, or the viewed account's for undecorated messages. */
|
|
function sourceBatchMailboxes(batch: SourceBatch): Mailbox[] {
|
|
if (!batch.accountId) return resolveActionMailboxes();
|
|
if (!batch.clientAccountId) return [];
|
|
return sourceMailboxes(useEmailStore.getState(), batch.clientAccountId, batch.accountId);
|
|
}
|
|
|
|
/** A folder that messages of an aggregate list are moved to (resolveMoveDestination). */
|
|
interface MoveDestination {
|
|
/** The login whose folder list the folder was picked from. */
|
|
localAccountId: string | null;
|
|
/** The store id the folder was picked by. */
|
|
storeId: string;
|
|
/** The account that owns the folder, and the folder's id there. */
|
|
accountId: string | undefined;
|
|
mailboxId: string;
|
|
isShared: boolean;
|
|
}
|
|
|
|
/**
|
|
* The folder `storeId` names in the folder list of `localAccountId` - the
|
|
* login a drop target is listed under - or, by default, of the active login:
|
|
* the "Move to" menus offer its folders (`mailboxes`), whichever login was
|
|
* browsed before the list that mixes accounts was opened.
|
|
*/
|
|
function resolveMoveDestination(passedClient: IJMAPClient, storeId: string, localAccountId?: string | null): MoveDestination {
|
|
const state = useEmailStore.getState();
|
|
const auth = useAuthStore.getState();
|
|
const login = localAccountId ?? auth.activeAccountId;
|
|
const isActive = login === auth.activeAccountId;
|
|
const mailboxes = (isActive ? state.mailboxes : (login ? state.accountMailboxes[login] : undefined)) ?? [];
|
|
const mailbox = mailboxes.find((mb) => mb.id === storeId);
|
|
const client = (login ? auth.getClientForAccount(login) : undefined) ?? (isActive ? passedClient : undefined);
|
|
return {
|
|
localAccountId: login,
|
|
storeId,
|
|
accountId: mailbox?.accountId ?? client?.getAccountId(),
|
|
mailboxId: mailbox?.originalId || storeId,
|
|
isShared: !!mailbox?.isShared,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Moves the messages of an aggregate list into one folder. Only the messages
|
|
* of the login and account that own the folder change folders in place: a
|
|
* mailbox id means nothing in another account, where the same id - Stalwart
|
|
* numbers the folders of every account alike - names that account's own
|
|
* folder, or another one. The others go through the cross-account move
|
|
* (crossAccountMoveEmails): Email/copy within one login, a download and an
|
|
* import across logins. Messages whose login is gone stay where they are.
|
|
*
|
|
* Returns the rows moved in place (the cross-account move takes the rows it
|
|
* moved off the list itself) and what failed.
|
|
*/
|
|
async function moveSourceBatches<T extends SourceStamped>(
|
|
batches: SourceBatch<T>[],
|
|
destination: MoveDestination,
|
|
passedClient: IJMAPClient,
|
|
): Promise<{ moved: T[]; failures: BatchFailure[] }> {
|
|
const callerLogin = useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId;
|
|
const moved: T[] = [];
|
|
const failures: BatchFailure[] = [];
|
|
const inPlace: Array<{ batch: SourceBatch<T>; client: IJMAPClient }> = [];
|
|
const across: Array<{ batch: SourceBatch<T>; login: string }> = [];
|
|
for (const batch of batches) {
|
|
const client = sourceBatchClient(batch, passedClient);
|
|
const login = batch.accountId ? batch.clientAccountId : callerLogin;
|
|
if (!client || !login) {
|
|
failures.push({ ids: batch.ids, error: new Error('Not connected') });
|
|
} else if (login === destination.localAccountId && (batch.accountId ?? client.getAccountId()) === destination.accountId) {
|
|
inPlace.push({ batch, client });
|
|
} else {
|
|
across.push({ batch, login });
|
|
}
|
|
}
|
|
|
|
const outcomes = await Promise.allSettled(
|
|
inPlace.map(({ batch, client }) => client.batchMoveEmails(batch.ids, destination.mailboxId, batch.accountId)),
|
|
);
|
|
outcomes.forEach((outcome, i) => {
|
|
const { batch } = inPlace[i];
|
|
if (outcome.status === 'fulfilled') {
|
|
moved.push(...batch.messages);
|
|
return;
|
|
}
|
|
// A refusal names the messages the server left alone; the rest moved.
|
|
const refused = new Set(jmapErrorFailedIds(outcome.reason) ?? batch.ids);
|
|
moved.push(...batch.messages.filter((message) => !refused.has(message.id)));
|
|
failures.push({ ids: batch.ids.filter((id) => refused.has(id)), error: outcome.reason });
|
|
});
|
|
|
|
// One at a time: each run reports through the store's loading state.
|
|
for (const { batch, login } of across) {
|
|
try {
|
|
if (!destination.localAccountId) throw new Error('Destination account is not connected');
|
|
await useEmailStore.getState().crossAccountMoveEmails(
|
|
new Map([[login, batch.ids]]),
|
|
destination.localAccountId,
|
|
destination.mailboxId,
|
|
destination.isShared ? destination.accountId : undefined,
|
|
batch.accountId,
|
|
);
|
|
} catch (error) {
|
|
const listed = new Set(useEmailStore.getState().emails.map((email) => email.id));
|
|
failures.push({ ids: batch.ids.filter((id) => listed.has(id)), error });
|
|
}
|
|
}
|
|
return { moved, failures };
|
|
}
|
|
|
|
/**
|
|
* Moves rows of an aggregate list into one folder (moveSourceBatches). The
|
|
* rows that moved leave the list and the selection, with their folders'
|
|
* counters; the others stay. Returns what failed.
|
|
*/
|
|
async function moveRowsToMailbox(rows: Email[], destination: MoveDestination, passedClient: IJMAPClient): Promise<BatchFailure[]> {
|
|
const { moved, failures } = await moveSourceBatches(groupBySource(rows), destination, passedClient);
|
|
if (moved.length === 0) return failures;
|
|
const movedKeys = new Set(moved.map(sourceRowKey));
|
|
const unread = moved.filter((email) => !email.keywords?.$seen).length;
|
|
const arrive = (mailbox: Mailbox) => (mailbox.id === destination.storeId
|
|
? { ...mailbox, totalEmails: mailbox.totalEmails + moved.length, unreadEmails: mailbox.unreadEmails + unread }
|
|
: mailbox);
|
|
useEmailStore.setState((state) => {
|
|
// Out of each message's own account list, into the folder's login's list.
|
|
const patch = applyBatchMailboxCounterUpdate(state, moved, applyMoveOutCounters);
|
|
const activeId = useAuthStore.getState().activeAccountId;
|
|
if (!destination.localAccountId || destination.localAccountId === activeId) {
|
|
patch.mailboxes = patch.mailboxes.map(arrive);
|
|
} else if (patch.accountMailboxes[destination.localAccountId]) {
|
|
patch.accountMailboxes[destination.localAccountId] = patch.accountMailboxes[destination.localAccountId].map(arrive);
|
|
}
|
|
const selectedEmailKeys = new Set(state.selectedEmailKeys);
|
|
for (const email of moved) selectedEmailKeys.delete(emailKeyFor(email));
|
|
return {
|
|
emails: state.emails.filter((email) => !movedKeys.has(sourceRowKey(email))),
|
|
selectedEmail: isOneOfRows(state.selectedEmail, movedKeys) ? null : state.selectedEmail,
|
|
selectedEmailKeys,
|
|
...patch,
|
|
};
|
|
});
|
|
return failures;
|
|
}
|
|
|
|
/**
|
|
* Batch archive in a list that mixes accounts: each login and account
|
|
* archives into its own Archive. One Archive for the whole selection moved
|
|
* every message by its id in that Archive's account, where the ids of the
|
|
* other accounts' messages name unrelated messages - or none. The rows that
|
|
* were archived leave the list, the selection, the viewer and expanded
|
|
* threads; the others stay and the failure is reported through `error`,
|
|
* `notFound` selection keys (resolveSelectionRows) included. Throws when
|
|
* nothing was archived; resolves false when a plugin stopped a group.
|
|
*
|
|
* Each group reads its folders through its own login and creates its Archive
|
|
* when the account has none, as the folder view does (Stalwart makes no
|
|
* Archive). Plugins are asked per group, each about its own Archive.
|
|
*/
|
|
async function archiveAggregateSelection(passedClient: IJMAPClient, selected: Email[], notFound: string[] = []): Promise<boolean> {
|
|
if (selected.length === 0 && notFound.length === 0) return true;
|
|
const mode = useSettingsStore.getState().archiveMode;
|
|
const { selectedMailbox } = useEmailStore.getState();
|
|
useEmailStore.setState({ isLoading: true, error: null });
|
|
|
|
const archived: Email[] = [];
|
|
const failures: BatchFailure[] = [];
|
|
let stoppedByPlugin = false;
|
|
await Promise.all(groupBySource(selected).map(async (batch) => {
|
|
const client = sourceBatchClient(batch, passedClient);
|
|
let move: Parameters<typeof allowEmailMove>[0] | null = null;
|
|
let hookMailboxes: Mailbox[] = [];
|
|
try {
|
|
if (!client) throw new Error('Not connected');
|
|
// The source's folders as its login reads them now, by the server's own
|
|
// ids: batchArchiveEmails finds year/month folders by their parent's id
|
|
// there, not by the store's namespaced one.
|
|
const folders = async (refresh: boolean): Promise<Mailbox[]> => {
|
|
if (!batch.accountId) {
|
|
if (refresh) await refreshMailboxesForViewingAccount(passedClient);
|
|
return resolveActionMailboxes();
|
|
}
|
|
return (await client.getMailboxes(batch.accountId)).map((m) => (m.originalId && m.accountId ? {
|
|
...m, id: m.originalId,
|
|
parentId: m.parentId?.startsWith(`${m.accountId}:`) ? m.parentId.slice(m.accountId.length + 1) : m.parentId,
|
|
} : m));
|
|
};
|
|
let mailboxes = await folders(false);
|
|
const archive = await ensureArchiveMailbox({
|
|
client, mailboxes, selectedMailboxId: selectedMailbox, accountId: batch.accountId,
|
|
refresh: async () => (mailboxes = await folders(true)),
|
|
});
|
|
if (!archive) {
|
|
stoppedByPlugin = true;
|
|
return;
|
|
}
|
|
if (archive.myRights?.mayAddItems === false) throw new ArchiveMailboxNotFoundError();
|
|
hookMailboxes = [...mailboxes, archive];
|
|
move = {
|
|
emailIds: [...new Set(batch.ids)],
|
|
fromMailboxId: moveSourceMailboxId(batch.messages, hookMailboxes),
|
|
toMailboxId: archive.id,
|
|
};
|
|
if (!(await allowEmailMove(move, hookMailboxes))) {
|
|
stoppedByPlugin = true;
|
|
return;
|
|
}
|
|
await client.batchArchiveEmails(
|
|
batch.messages.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
|
|
archive.originalId || archive.id,
|
|
mode,
|
|
mailboxes,
|
|
archive.accountId,
|
|
);
|
|
archived.push(...batch.messages);
|
|
emitEmailMove(move, hookMailboxes);
|
|
} catch (error) {
|
|
// An archive that failed part way names the messages it did move.
|
|
const moved = new Set(error instanceof ArchiveEmailsError ? error.updatedIds : []);
|
|
archived.push(...batch.messages.filter(e => moved.has(e.id)));
|
|
failures.push({ ids: batch.ids.filter(id => !moved.has(id)), error });
|
|
if (move && moved.size > 0) emitEmailMove({ ...move, emailIds: move.emailIds.filter(id => moved.has(id)) }, hookMailboxes);
|
|
}
|
|
}));
|
|
failures.push(...notFoundFailures(notFound));
|
|
|
|
const error = failures.length > 0 ? batchFailureMessage(failures, 'archive') : null;
|
|
if (archived.length === 0) {
|
|
useEmailStore.setState({ error, isLoading: false });
|
|
if (failures.length > 0) throw failures[0].error;
|
|
return false;
|
|
}
|
|
const archivedKeys = new Set(archived.map(sourceRowKey));
|
|
useEmailStore.setState((state) => {
|
|
const selectedEmailKeys = new Set(state.selectedEmailKeys);
|
|
for (const email of archived) selectedEmailKeys.delete(emailKeyFor(email));
|
|
const threadEmailsCache = new Map(state.threadEmailsCache);
|
|
const expandedThreadIds = new Set(state.expandedThreadIds);
|
|
for (const [key, members] of threadEmailsCache) {
|
|
const kept = members.filter(e => !archivedKeys.has(sourceRowKey(e)));
|
|
if (kept.length === members.length) continue;
|
|
if (kept.length > 0) threadEmailsCache.set(key, kept);
|
|
else {
|
|
threadEmailsCache.delete(key);
|
|
expandedThreadIds.delete(key);
|
|
}
|
|
}
|
|
return {
|
|
emails: state.emails.filter(e => !archivedKeys.has(sourceRowKey(e))),
|
|
selectedEmail: isOneOfRows(state.selectedEmail, archivedKeys) ? null : state.selectedEmail,
|
|
selectedEmailKeys,
|
|
threadEmailsCache,
|
|
expandedThreadIds,
|
|
isLoading: false,
|
|
error,
|
|
};
|
|
});
|
|
|
|
// A year/month archive can create new sub-folders.
|
|
await refreshMailboxesForViewingAccount(passedClient);
|
|
await useEmailStore.getState().refreshCurrentMailbox(passedClient);
|
|
return !stoppedByPlugin;
|
|
}
|
|
|
|
/**
|
|
* The emails the key-based selection names (resolveSelectedEmails): listed
|
|
* rows and loaded members of expanded threads, each keyed by its own login
|
|
* and account. A selected key found in neither - a row that left the list
|
|
* after it was ticked - is returned as `unknown` for the caller to report
|
|
* and keep selected, never written in the account the list happens to be
|
|
* reached through.
|
|
*/
|
|
function resolveSelectionRows(): { rows: Email[]; unknown: string[] } {
|
|
const state = useEmailStore.getState();
|
|
const rows = resolveSelectedEmails(state);
|
|
const found = new Set(rows.map(emailKeyFor));
|
|
return { rows, unknown: [...state.selectedEmailKeys].filter((key) => !found.has(key)) };
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
}
|
|
|
|
// Whether an email belongs to a given mailbox, for local counter math.
|
|
// Own-account emails carry bare ids (equal to both `id` and `originalId`).
|
|
// As of #281 V3, EVERY client fetch path (getEmails/getEmail/getThreadEmails/
|
|
// searchEmails/advancedSearchEmails) namespaces shared/delegated emails'
|
|
// `mailboxIds` to the store id (`${ownerId}:${origId}`), so the `ids[mailbox.id]`
|
|
// fast path below matches own and shared mailboxes alike - one id space.
|
|
// The `originalId` branches remain as a defensive fallback for any email that
|
|
// still carries a bare owner id (scoped to the owning account via
|
|
// `sourceAccountId === mailbox.accountId` so a bare owner id can't collide with
|
|
// another account's folder). (#281)
|
|
/**
|
|
* The folder messages are moved out of, for the move hooks: the open folder,
|
|
* or in an aggregate view (unified, tag, search across folders) the one
|
|
* folder they all share, else null.
|
|
*/
|
|
function moveSourceMailboxId(emails: Array<Pick<Email, 'mailboxIds' | 'sourceAccountId'>>, mailboxes: Mailbox[]): string | null {
|
|
const { selectedMailbox, selectedKeyword, isUnifiedView } = useEmailStore.getState();
|
|
if (!selectedKeyword && !isUnifiedView && selectedMailbox && mailboxes.some(mb => mb.id === selectedMailbox)) {
|
|
return selectedMailbox;
|
|
}
|
|
const sources = new Set<string>();
|
|
for (const email of emails) {
|
|
const mailbox = mailboxes.find(mb => emailInMailbox(email, mb));
|
|
if (!mailbox) return null;
|
|
sources.add(mailbox.id);
|
|
}
|
|
return sources.size === 1 ? [...sources][0] : null;
|
|
}
|
|
|
|
function emailInMailbox(
|
|
email: { mailboxIds?: Record<string, boolean>; sourceAccountId?: string },
|
|
mailbox: Mailbox,
|
|
): boolean {
|
|
const ids = email.mailboxIds;
|
|
if (!ids) return false;
|
|
if (ids[mailbox.id]) return true;
|
|
if (!mailbox.isShared && mailbox.originalId) return !!ids[mailbox.originalId];
|
|
if (
|
|
mailbox.isShared &&
|
|
mailbox.originalId &&
|
|
email.sourceAccountId &&
|
|
email.sourceAccountId === mailbox.accountId
|
|
) {
|
|
return !!ids[mailbox.originalId];
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* The folder "Remove from this folder" takes messages out of (#615): the
|
|
* folder the list shows, when it shows exactly one. A search scoped to one
|
|
* folder counts as that folder. Null in the unified, cross-account, tag and
|
|
* scheduled views and in a search across folders, where no single folder is
|
|
* open.
|
|
*/
|
|
export function removalFolder(
|
|
state: Pick<EmailStore,
|
|
| 'isUnifiedView' | 'crossView' | 'selectedKeyword' | 'isScheduledView'
|
|
| 'selectedMailbox' | 'searchMailboxId' | 'searchQuery' | 'searchFilters'
|
|
| 'viewingAccountId' | 'accountMailboxes' | 'mailboxes'>,
|
|
): Mailbox | null {
|
|
if (state.isUnifiedView || state.crossView || state.selectedKeyword || state.isScheduledView) return null;
|
|
const searching = !!state.searchQuery || !isFilterEmpty(state.searchFilters);
|
|
if (searching && isAllFoldersSearchScope(state.searchMailboxId)) return null;
|
|
const folderId = searching ? state.searchMailboxId : state.selectedMailbox;
|
|
if (!folderId) return null;
|
|
return mailboxesInView(state).find(mb => mb.id === folderId) ?? null;
|
|
}
|
|
|
|
/**
|
|
* Whether a message can leave `folder` and still be somewhere: it is in that
|
|
* folder and in at least one other. JMAP lets a message sit in several
|
|
* mailboxes at once, but never in none.
|
|
*/
|
|
export function canRemoveFromFolder(
|
|
email: Pick<Email, 'mailboxIds' | 'sourceAccountId'>,
|
|
folder: Mailbox | null,
|
|
): boolean {
|
|
if (!folder || !emailInMailbox(email, folder)) return false;
|
|
return Object.values(email.mailboxIds ?? {}).filter(Boolean).length > 1;
|
|
}
|
|
|
|
// Apply a per-mailbox counter adjustment to the mailbox list that actually holds
|
|
// the email's folders, and return the matching `set()` partial.
|
|
//
|
|
// In aggregate views an email may belong to another logged-in account. The
|
|
// sidebar shows the active account's folders from `mailboxes` (its
|
|
// getAllMailboxes, incl. its delegated shared folders) and every *other*
|
|
// account's folders from `accountMailboxes[<AccountEntry.id>]`. Updating the
|
|
// wrong list silently corrupts counters - and because JMAP mailbox ids can
|
|
// collide across accounts, blindly matching `mailboxes` would even decrement the
|
|
// *active* account's folder for a different account's email. So route by the
|
|
// email's source login: a different account → its `accountMailboxes` entry,
|
|
// otherwise (active account, its shared folders, or a non-aggregate view) →
|
|
// `mailboxes`. (#281)
|
|
function applyMailboxCounterUpdate(
|
|
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
|
|
email: { sourceClientAccountId?: string },
|
|
adjust: (mb: Mailbox) => Mailbox,
|
|
): { mailboxes?: Mailbox[]; accountMailboxes?: Record<string, Mailbox[]> } {
|
|
const srcClient = email.sourceClientAccountId;
|
|
const activeId = useAuthStore.getState().activeAccountId;
|
|
if (srcClient && srcClient !== activeId) {
|
|
const list = state.accountMailboxes[srcClient];
|
|
if (!list) return {};
|
|
return { accountMailboxes: { ...state.accountMailboxes, [srcClient]: list.map(adjust) } };
|
|
}
|
|
return { mailboxes: state.mailboxes.map(adjust) };
|
|
}
|
|
|
|
// Batch variant of applyMailboxCounterUpdate: a set of emails may span accounts,
|
|
// so group them by the list that holds them and run `mapMailbox` per list using
|
|
// only that group's emails. Each account's counters are adjusted in its own list
|
|
// (active account → `mailboxes`, others → `accountMailboxes[sourceClientAccountId]`),
|
|
// avoiding cross-account id collisions. (#281)
|
|
function applyBatchMailboxCounterUpdate(
|
|
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
|
|
emails: Array<{ sourceClientAccountId?: string }>,
|
|
mapMailbox: (mb: Mailbox, emailsForList: Array<{ sourceClientAccountId?: string }>) => Mailbox,
|
|
): { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> } {
|
|
const activeId = useAuthStore.getState().activeAccountId;
|
|
const ACTIVE = '__active__';
|
|
const groups = new Map<string, Array<{ sourceClientAccountId?: string }>>();
|
|
for (const e of emails) {
|
|
const k = e.sourceClientAccountId && e.sourceClientAccountId !== activeId ? e.sourceClientAccountId : ACTIVE;
|
|
const g = groups.get(k);
|
|
if (g) g.push(e); else groups.set(k, [e]);
|
|
}
|
|
let mailboxes = state.mailboxes;
|
|
const accountMailboxes = { ...state.accountMailboxes };
|
|
for (const [k, es] of groups) {
|
|
if (k === ACTIVE) {
|
|
mailboxes = mailboxes.map((mb) => mapMailbox(mb, es));
|
|
} else if (accountMailboxes[k]) {
|
|
accountMailboxes[k] = accountMailboxes[k].map((mb) => mapMailbox(mb, es));
|
|
}
|
|
}
|
|
return { mailboxes, accountMailboxes };
|
|
}
|
|
|
|
/**
|
|
* The folder with `role` in one account's mailbox list. `accountId` scopes it
|
|
* to a shared/group owner; without it, own folders only. Own folders may or
|
|
* may not carry their account id, so a missing one counts as own - but not
|
|
* for an account the list holds shared folders of: the merged own+shared
|
|
* list of a shared-folder view lists the own Inbox first, without an id.
|
|
*/
|
|
function findRoleMailbox(mailboxes: Mailbox[], role: string, accountId: string | undefined): Mailbox | undefined {
|
|
const own = (m: Mailbox) => m.role === role && !m.isShared && !m.accountId;
|
|
if (!accountId) return mailboxes.find(m => m.role === role && !m.isShared);
|
|
const scoped = mailboxes.find(m => m.role === role && m.accountId === accountId);
|
|
if (scoped || mailboxes.some(m => m.isShared && m.accountId === accountId)) return scoped;
|
|
return mailboxes.find(own);
|
|
}
|
|
|
|
/**
|
|
* Batch spam / not-spam. Each message is routed like the single-message
|
|
* action: through its own account's client, to that account's junk or inbox.
|
|
* The batch used to send everything through the active client to the selected
|
|
* folder's account, so in unified, tag and search views it either failed for
|
|
* other accounts or returned early while the caller showed a success toast.
|
|
*
|
|
* Throws when any message could not be moved; the ones that were moved still
|
|
* leave the list.
|
|
*/
|
|
/**
|
|
* Mark every unread message matching `filter` read. The server-side bulk
|
|
* call doesn't say which messages it touched, so when a plugin listens the
|
|
* ids are listed first, plugins are asked once, and told afterwards.
|
|
* Resolves null when a plugin said no.
|
|
*/
|
|
async function markReadAskingPlugins(
|
|
client: IJMAPClient,
|
|
filter: Record<string, unknown>,
|
|
accountId: string | undefined,
|
|
bulk: (client: IJMAPClient) => Promise<number>,
|
|
): Promise<number | null> {
|
|
const asks = emailHooks.onBeforeEmailReadStateChange.size > 0;
|
|
const tells = emailHooks.onEmailReadStateChange.size > 0;
|
|
if (!asks && !tells) return bulk(client);
|
|
const unread = await client.queryEmailFields({ ...filter, notKeyword: '$seen' }, [], accountId, Number.MAX_SAFE_INTEGER);
|
|
const emailIds = unread.map(e => String(e.id));
|
|
if (emailIds.length === 0) return 0;
|
|
if (asks && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds, read: true }))) return null;
|
|
await client.batchMarkAsRead(emailIds, true, accountId);
|
|
void emailHooks.onEmailReadStateChange.emit({ emailIds, read: true });
|
|
return emailIds.length;
|
|
}
|
|
|
|
async function runBatchSpamAction(
|
|
get: StoreGet,
|
|
set: StoreSet,
|
|
client: IJMAPClient,
|
|
emailIds: string[],
|
|
role: 'junk' | 'inbox',
|
|
run: (actionClient: IJMAPClient, email: Email, accountId: string | undefined, destination: Mailbox | undefined) => Promise<void>,
|
|
arrivesUnread: (email: Email) => boolean,
|
|
): Promise<boolean> {
|
|
// Act on the selected emails themselves: bare ids collide across accounts,
|
|
// so looking them up in the list could pick another account's namesake.
|
|
const wanted = new Set(emailIds);
|
|
const state = get();
|
|
const selected = resolveSelectedEmails(state).filter(e => wanted.has(e.id));
|
|
const targets = selected.length > 0 ? selected : state.emails.filter(e => wanted.has(e.id));
|
|
if (targets.length === 0) {
|
|
throw new Error('Emails not found');
|
|
}
|
|
// Outside a list that mixes accounts the viewed folder names the account of
|
|
// its unstamped rows. Throw rather than guess when it is gone: the caller
|
|
// reports the mail as moved. (#695)
|
|
if (role === 'junk' && !isAggregateListView() && targets.some(e => !e.sourceAccountId)
|
|
&& !resolveActionMailboxes().some(m => m.id === state.selectedMailbox)) {
|
|
throw new Error('Mailbox not found - cannot mark as spam');
|
|
}
|
|
const spam = role === 'junk';
|
|
if (emailHooks.onBeforeEmailSpamToggle.size > 0
|
|
&& !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [...new Set(targets.map(e => e.id))], spam }))) {
|
|
return false;
|
|
}
|
|
|
|
const destinationByKey = new Map<string, string | undefined>();
|
|
const failures: unknown[] = [];
|
|
for (const email of targets) {
|
|
// A message of a login that is gone stays where it is: another login's
|
|
// client would write to a server where its id names another message.
|
|
if (email.sourceAccountId && email.sourceClientAccountId
|
|
&& !useAuthStore.getState().getClientForAccount(email.sourceClientAccountId)) {
|
|
failures.push(new Error('Not connected'));
|
|
continue;
|
|
}
|
|
const { client: actionClient, mailboxes, accountId } = resolveEmailActionContext(email, client);
|
|
const destination = findRoleMailbox(mailboxes, role, accountId);
|
|
if (!destination && role === 'inbox') {
|
|
failures.push(new Error('Inbox not found'));
|
|
continue;
|
|
}
|
|
try {
|
|
await run(actionClient, email, accountId, destination);
|
|
destinationByKey.set(emailKeyFor(email), destination?.id);
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
|
|
if (destinationByKey.size > 0) {
|
|
const moved = targets.filter(e => destinationByKey.has(emailKeyFor(e)));
|
|
set(s => {
|
|
// Counters change in the email's own account list (#281).
|
|
const patch = applyBatchMailboxCounterUpdate(s, moved, (mb, group) => {
|
|
let dTotal = 0;
|
|
let dUnread = 0;
|
|
for (const e of group as Email[]) {
|
|
if (emailInMailbox(e, mb)) {
|
|
dTotal--;
|
|
if (!e.keywords?.$seen) dUnread--;
|
|
}
|
|
if (destinationByKey.get(emailKeyFor(e)) === mb.id) {
|
|
dTotal++;
|
|
if (arrivesUnread(e)) dUnread++;
|
|
}
|
|
}
|
|
return dTotal === 0 && dUnread === 0 ? mb : {
|
|
...mb,
|
|
totalEmails: Math.max(0, mb.totalEmails + dTotal),
|
|
unreadEmails: Math.max(0, mb.unreadEmails + dUnread),
|
|
};
|
|
});
|
|
const isMoved = (e: Email) => destinationByKey.has(emailKeyFor(e));
|
|
return {
|
|
emails: s.emails.filter(e => !isMoved(e)),
|
|
selectedEmail: s.selectedEmail && isMoved(s.selectedEmail) ? null : s.selectedEmail,
|
|
selectedEmailKeys: new Set(),
|
|
...patch,
|
|
};
|
|
});
|
|
void emailHooks.onEmailSpamToggle.emit({ emailIds: [...new Set(moved.map(e => e.id))], spam });
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
console.error(`Batch ${role === 'junk' ? 'mark as spam' : 'not spam'} failed for ${failures.length} of ${targets.length} emails:`, failures);
|
|
throw failures[0] instanceof Error ? failures[0] : new Error('Batch spam action failed');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Sidebar tag badges render from `tagCounts`, which is *fetched from the server*
|
|
// (`fetchTagCounts` -> `getTagCounts`) rather than derived from `state.emails`.
|
|
// So a read/unread mutation has to keep it in step locally, exactly as it does
|
|
// for `mailboxes[].unreadEmails` - otherwise the tag unread count (and the bold
|
|
// tag name) stays stale until a full page reload.
|
|
//
|
|
// `changes` carries one entry per email whose read state *actually changed*
|
|
// (callers already compute that), with delta -1 when it became read and +1 when
|
|
// it became unread. Only `unread` moves: read state never changes tag
|
|
// membership, so `total` is left alone.
|
|
function applyTagCountReadDelta(
|
|
tagCounts: Record<string, { total: number; unread: number }>,
|
|
changes: Array<{ keywords?: Record<string, boolean>; delta: number }>,
|
|
): Record<string, { total: number; unread: number }> {
|
|
const keywordIds = useSettingsStore.getState().emailKeywords.map(k => k.id);
|
|
if (keywordIds.length === 0) return tagCounts;
|
|
|
|
let next: Record<string, { total: number; unread: number }> | null = null;
|
|
for (const { keywords, delta } of changes) {
|
|
if (!keywords || delta === 0) continue;
|
|
for (const id of keywordIds) {
|
|
if (!keywords[`$label:${id}`]) continue;
|
|
const current = (next ?? tagCounts)[id];
|
|
if (!current) continue; // Tag not in the fetched counts yet; nothing to adjust.
|
|
next = next ?? { ...tagCounts };
|
|
next[id] = { total: current.total, unread: Math.max(0, current.unread + delta) };
|
|
}
|
|
}
|
|
return next ?? tagCounts;
|
|
}
|
|
|
|
// Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for removing a
|
|
// group of emails from a folder: decrement total (and unread for unseen) for
|
|
// each group email that lives in the mailbox.
|
|
function applyDeleteCounters(
|
|
mailbox: Mailbox,
|
|
group: Array<{ sourceClientAccountId?: string }>,
|
|
): Mailbox {
|
|
let dTotal = 0;
|
|
let dUnread = 0;
|
|
for (const email of group as Email[]) {
|
|
if (emailInMailbox(email, mailbox)) {
|
|
dTotal--;
|
|
if (!email.keywords?.$seen) dUnread--;
|
|
}
|
|
}
|
|
return dTotal === 0 && dUnread === 0 ? mailbox : {
|
|
...mailbox,
|
|
totalEmails: Math.max(0, mailbox.totalEmails + dTotal),
|
|
unreadEmails: Math.max(0, mailbox.unreadEmails + dUnread),
|
|
totalThreads: Math.max(0, mailbox.totalThreads + dTotal),
|
|
unreadThreads: Math.max(0, mailbox.unreadThreads + dUnread),
|
|
};
|
|
}
|
|
|
|
// Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for moving a
|
|
// group of emails out of their folders: message counts only, like the
|
|
// destination's gain.
|
|
function applyMoveOutCounters(
|
|
mailbox: Mailbox,
|
|
group: Array<{ sourceClientAccountId?: string }>,
|
|
): Mailbox {
|
|
let dTotal = 0;
|
|
let dUnread = 0;
|
|
for (const email of group as Email[]) {
|
|
if (emailInMailbox(email, mailbox)) {
|
|
dTotal--;
|
|
if (!email.keywords?.$seen) dUnread--;
|
|
}
|
|
}
|
|
return dTotal === 0 && dUnread === 0 ? mailbox : {
|
|
...mailbox,
|
|
totalEmails: Math.max(0, mailbox.totalEmails + dTotal),
|
|
unreadEmails: Math.max(0, mailbox.unreadEmails + dUnread),
|
|
};
|
|
}
|
|
|
|
// ─── Unified-badge live projection ────────────────────────────────────────────
|
|
//
|
|
// The unified-section badges (unifiedCounts / crossUnreadCount) are a pure
|
|
// projection of the live per-account mailbox lists - the SAME lists the
|
|
// optimistic delete/move/markRead paths patch and that push refreshes. Rather
|
|
// than trusting the counter snapshot baked into the UnifiedAccountClient[] (which
|
|
// came from a server fetch and goes stale the moment a local mutation runs), we
|
|
// look each scope mailbox up by id in the live store list and use its current
|
|
// counters. This keeps the badges in lockstep with the per-folder counters - one
|
|
// source of truth, no server round trip, no eventual-consistency snap-back.
|
|
|
|
// The live store list that holds a unified-scope account's folders. Mirrors
|
|
// applyMailboxCounterUpdate's routing exactly: the active client's folders (incl.
|
|
// its delegated shared folders) live in `mailboxes`; every other logged-in
|
|
// account's folders live in `accountMailboxes[clientAccountId]`. Falls back to
|
|
// the account's own (snapshot) list so an unknown account still contributes its
|
|
// last-known counters instead of vanishing.
|
|
function liveListForAccount(
|
|
account: UnifiedAccountClient,
|
|
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
|
|
): Mailbox[] {
|
|
const activeId = useAuthStore.getState().activeAccountId;
|
|
if (account.clientAccountId === activeId) return state.mailboxes;
|
|
return state.accountMailboxes[account.clientAccountId] ?? account.mailboxes;
|
|
}
|
|
|
|
// Returns a shallow copy of the scope account whose mailboxes carry LIVE counter
|
|
// values (matched by id against the live store list). Structure - which
|
|
// mailboxes, their roles, originalId, crossIncludedMailboxIds - is preserved from
|
|
// the scope snapshot; only the counter numbers are refreshed. This lets the
|
|
// existing lib aggregators (fetchUnifiedMailboxCounts / getCrossUnreadTotal) run
|
|
// unchanged over live data.
|
|
function accountWithLiveCounters(
|
|
account: UnifiedAccountClient,
|
|
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
|
|
): UnifiedAccountClient {
|
|
const live = liveListForAccount(account, state);
|
|
const byId = new Map(live.map((m) => [m.id, m]));
|
|
return { ...account, mailboxes: account.mailboxes.map((m) => byId.get(m.id) ?? m) };
|
|
}
|
|
|
|
// Project the live mailbox state over the current unified scope into the two
|
|
// badge values. Pure function of (unifiedScope, mailboxes, accountMailboxes).
|
|
function projectUnifiedCounts(
|
|
state: { unifiedScope: UnifiedAccountClient[]; mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
|
|
): { unifiedCounts: UnifiedMailboxCounts[]; crossUnreadCount: number } {
|
|
if (state.unifiedScope.length === 0) {
|
|
return { unifiedCounts: [], crossUnreadCount: 0 };
|
|
}
|
|
const live = state.unifiedScope.map((a) => accountWithLiveCounters(a, state));
|
|
return {
|
|
unifiedCounts: fetchUnifiedMailboxCounts(live),
|
|
crossUnreadCount: getCrossUnreadTotal(live),
|
|
};
|
|
}
|
|
|
|
// 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');
|
|
});
|
|
}
|
|
|
|
// Deleting a selection in this folder destroys it rather than moving it to
|
|
// the trash: in Trash, and in Junk when the user chose that. batchDelete
|
|
// decides the same way.
|
|
export function bulkDeleteIsPermanent(mailbox: { role?: string | null } | undefined): boolean {
|
|
if (mailbox?.role === 'trash') return true;
|
|
return mailbox?.role === 'junk' && useSettingsStore.getState().permanentlyDeleteJunk;
|
|
}
|
|
|
|
// Whether deleting several selected emails asks first. A permanent delete
|
|
// always does; moving them to the trash only while the user keeps the
|
|
// prompt on. (#1225)
|
|
export function shouldConfirmBulkDelete(mailbox: { role?: string | null } | undefined): boolean {
|
|
return bulkDeleteIsPermanent(mailbox) || useSettingsStore.getState().confirmBulkDelete;
|
|
}
|
|
|
|
// Emptying Trash or Junk destroys their mail, as does every delete for users
|
|
// who chose permanent deletion; any other folder is emptied into the trash.
|
|
export function emptyFolderMovesToTrash(mailbox: Pick<Mailbox, 'role'>): boolean {
|
|
if (mailbox.role === 'trash' || mailbox.role === 'junk') return false;
|
|
return useSettingsStore.getState().deleteAction !== 'permanent';
|
|
}
|
|
|
|
// Plugin re-render for already fetched emails.
|
|
// Plugins can trigger: window.dispatchEvent(new Event('plugin:rerender-fetched-emails'))
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('plugin:rerender-fetched-emails', async () => {
|
|
const state = useEmailStore.getState();
|
|
const transformed = await emailHooks.onEmailsFetched.transform(state.emails);
|
|
useEmailStore.setState({
|
|
emails: annotateScheduledEmails(transformed, state.scheduledSubmissionByEmailId),
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The two cross views whose filter is the very keyword the read/star actions
|
|
* flip: 'unread' (notKeyword $seen) and 'starred' (hasKeyword $flagged). In
|
|
* those views a just-read / just-unstarred mail no longer matches the query, so
|
|
* a naive push refresh drops it. We keep such rows (see retainedInViewIds).
|
|
* The 'all' view has no keyword filter and needs no retention.
|
|
*/
|
|
function retainingCrossView(state: { isUnifiedView: boolean; crossView: CrossView | null }): 'unread' | 'starred' | null {
|
|
if (!state.isUnifiedView) return null;
|
|
return state.crossView === 'unread' || state.crossView === 'starred' ? state.crossView : null;
|
|
}
|
|
|
|
/**
|
|
* Immutably add/remove an id from the retain set. Returns the same set instance
|
|
* when nothing changes (op 'none', or a no-op add/remove) to avoid needless
|
|
* re-renders.
|
|
*/
|
|
function updateRetainedForView(current: Set<string>, op: 'add' | 'remove' | 'none', id: string): Set<string> {
|
|
if (op === 'none') return current;
|
|
if (op === 'add') {
|
|
if (current.has(id)) return current;
|
|
return new Set(current).add(id);
|
|
}
|
|
if (!current.has(id)) return current;
|
|
const next = new Set(current);
|
|
next.delete(id);
|
|
return next;
|
|
}
|
|
|
|
/**
|
|
* Re-insert rows the user just read/unstarred in an unread/starred cross view
|
|
* that fell out of the fresh keyword-filtered page, keeping them at their prior
|
|
* position so the row updates its status + counter instead of vanishing from
|
|
* under the cursor. Rows genuinely gone (deleted/moved, hence absent from
|
|
* `previous`) are NOT resurrected - only ids still present locally are kept.
|
|
* Returns the original `merged` untouched when nothing needs retaining.
|
|
*/
|
|
function mergeRetainedRows(previous: Email[], merged: Email[], retainedIds: Set<string>): Email[] {
|
|
if (retainedIds.size === 0) return merged;
|
|
const mergedById = new Map(merged.map(e => [e.id, e] as const));
|
|
const toRetain = previous.filter(e => retainedIds.has(e.id) && !mergedById.has(e.id));
|
|
if (toRetain.length === 0) return merged;
|
|
|
|
const previousIds = new Set(previous.map(e => e.id));
|
|
const out: Email[] = [];
|
|
const emitted = new Set<string>();
|
|
// Genuinely new arrivals (not previously loaded) keep their fresh top slot.
|
|
for (const e of merged) {
|
|
if (!previousIds.has(e.id)) { out.push(e); emitted.add(e.id); }
|
|
}
|
|
// Then previous order: fresh copy if the row still matches, retained local
|
|
// copy otherwise. Rows absent from both `merged` and the retain set are the
|
|
// truly-removed ones and get dropped here.
|
|
for (const e of previous) {
|
|
if (emitted.has(e.id)) continue;
|
|
const fresh = mergedById.get(e.id);
|
|
if (fresh) { out.push(fresh); emitted.add(e.id); }
|
|
else if (retainedIds.has(e.id)) { out.push(e); emitted.add(e.id); }
|
|
}
|
|
// Any deeper-page rows merged in but not covered above, in merged order.
|
|
for (const e of merged) {
|
|
if (!emitted.has(e.id)) { out.push(e); emitted.add(e.id); }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Most message ids a plugin may add to one search through onSearchResults. */
|
|
const MAX_PLUGIN_SEARCH_IDS = 100;
|
|
|
|
async function pluginSearchResultIds(query: string, filters: unknown, emails: Email[]): Promise<string[]> {
|
|
if (emailHooks.onSearchResults.size === 0) return [];
|
|
const shown = new Set(emails.map((e) => e.id));
|
|
const ids = await emailHooks.onSearchResults.transform([] as string[], { query, filters, emailIds: [...shown] });
|
|
if (!Array.isArray(ids)) return [];
|
|
return [...new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0 && id.length < 256 && !shown.has(id)))]
|
|
.slice(0, MAX_PLUGIN_SEARCH_IDS);
|
|
}
|
|
|
|
/**
|
|
* Plugins' results for the search that is running (onProvideSearchResults).
|
|
* Asked beside the search, not before its list is shown: a slow plugin never
|
|
* delays the messages, and the answer to a search that was replaced is dropped.
|
|
*/
|
|
let providedSearchRun: object | null = null;
|
|
|
|
function startProvidedSearchResults(
|
|
query: string,
|
|
filters: unknown,
|
|
store: { get: () => EmailStore; set: (partial: Partial<EmailStore>) => void },
|
|
): void {
|
|
const run = {};
|
|
providedSearchRun = run;
|
|
// Written only when it changes: a search without plugin results renders as before.
|
|
const show = (results: ProvidedSearchResult[]) => {
|
|
if (results.length > 0 || store.get().externalSearchResults.length > 0) store.set({ externalSearchResults: results });
|
|
};
|
|
show([]);
|
|
if (emailHooks.onProvideSearchResults.size === 0) return;
|
|
void provideSearchResults(query, filters).then((results) => {
|
|
if (providedSearchRun === run) show(results);
|
|
});
|
|
}
|
|
|
|
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(),
|
|
selectedEmailKeys: new Set(),
|
|
retainedInViewIds: new Set(),
|
|
lastSelectedEmailKey: null,
|
|
selectionFocus: null,
|
|
listRows: null,
|
|
hasMoreEmails: false,
|
|
totalEmails: 0,
|
|
listOrder: [],
|
|
listKeepsServerTextOrder: false,
|
|
listHold: null,
|
|
isPushConnected: false,
|
|
lastPushUpdate: null,
|
|
emailListSync: null,
|
|
unreadSection: null,
|
|
mailboxSyncStates: {},
|
|
newEmailNotification: null,
|
|
|
|
// Thread expansion state
|
|
expandedThreadIds: new Set(),
|
|
threadEmailsCache: new Map(),
|
|
isLoadingThread: null,
|
|
threadEmailCounts: new Map(),
|
|
|
|
// Keyword/tag filter
|
|
selectedKeyword: null,
|
|
tagCounts: {},
|
|
|
|
// Advanced search state
|
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
|
searchMailboxId: "",
|
|
searchScopeFollowsFolder: false,
|
|
isAdvancedSearchOpen: false,
|
|
searchAbortController: null,
|
|
externalSearchResults: [],
|
|
|
|
// Unified mailbox state
|
|
viewToken: 0,
|
|
isUnifiedView: false,
|
|
unifiedRole: null,
|
|
crossView: null,
|
|
unifiedErrors: new Map(),
|
|
unifiedCounts: [],
|
|
crossUnreadCount: 0,
|
|
unifiedScope: [],
|
|
|
|
// Scheduled send state
|
|
scheduledEmails: [],
|
|
scheduledEmailIds: new Set(),
|
|
scheduledSubmissionByEmailId: new Map(),
|
|
scheduledTotal: 0,
|
|
scheduledHasMore: false,
|
|
scheduledNextPosition: 0,
|
|
scheduledTotalByAccount: {},
|
|
isLoadingScheduled: false,
|
|
isScheduledView: false,
|
|
scheduledAccountScope: null,
|
|
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(state => ({
|
|
viewingAccountId: accountId,
|
|
selectedMailbox: mailboxId,
|
|
// The search panel's folder scope names a folder of the account we are
|
|
// leaving. It resolves against `resolveActionMailboxes()`, which now
|
|
// returns the NEW account's list, so the id no longer matches: the search
|
|
// would query that foreign folder id against the wrong account and come
|
|
// back empty, while the dropdown (which renders no matching option) reads
|
|
// "All folders". Reset it so the scope matches what is shown. (#1082)
|
|
// Only the new account's own list names its Spam/Trash: another
|
|
// account's mailbox ids can collide with them. A scope that follows
|
|
// the open folder moves to the new one.
|
|
searchMailboxId: state.searchScopeFollowsFolder
|
|
? mailboxId
|
|
: defaultSearchScopeFor(
|
|
accountId ? state.accountMailboxes[accountId] ?? [] : state.mailboxes,
|
|
mailboxId,
|
|
),
|
|
isLoadingMore: false,
|
|
selectedEmail: null,
|
|
selectedEmailKeys: new Set(),
|
|
selectedKeyword: null,
|
|
expandedThreadIds: new Set(),
|
|
threadEmailsCache: new Map(),
|
|
threadEmailCounts: new Map(),
|
|
isLoadingThread: null,
|
|
// Account folders are always real folders; like selectMailbox, leave any
|
|
// unified view so a pending unified fetch or refresh can't take it over (#1102).
|
|
viewToken: state.viewToken + 1,
|
|
isUnifiedView: false,
|
|
unifiedRole: null,
|
|
crossView: null,
|
|
})),
|
|
fetchAccountMailboxes: async (client, accountId, clientAccountId) => {
|
|
try {
|
|
// `accountId` is overloaded across callers:
|
|
// - a real login (AccountEntry.id, e.g. per-account sidebar / cross-account
|
|
// move) → `client` is that account's own login; getMailboxes() returns the
|
|
// right list.
|
|
// - a JMAP account id with no own login (a shared/group owner, used by the
|
|
// unified archive refresh) → must fetch by that JMAP id through the
|
|
// delegating client, else we'd cache the delegating account's own folders
|
|
// under the owner key.
|
|
// Distinguish by whether a directly-logged-in client exists for the id.
|
|
const hasOwnLogin = !!useAuthStore.getState().getClientForAccount(accountId);
|
|
const mailboxes = hasOwnLogin
|
|
? await client.getMailboxes()
|
|
: await client.getMailboxes(accountId);
|
|
// 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,
|
|
...(clientAccountId ? { [sourceMailboxesKey(clientAccountId, accountId)]: mailboxes } : {}),
|
|
},
|
|
}));
|
|
} catch (error) {
|
|
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
|
|
}
|
|
},
|
|
selectEmail: (email) => {
|
|
const prev = get().selectedEmail;
|
|
set({ selectedEmail: email, lastSelectedEmailKey: email ? emailKeyFor(email) : get().lastSelectedEmailKey });
|
|
// Plain data only: handlers in a sandbox get a copy, and the raw store
|
|
// object carries fields no plugin permission covers.
|
|
if (prev && (!email || email.id !== prev.id) && emailHooks.onEmailClose.size > 0) {
|
|
emailHooks.onEmailClose.emitSync({ emailId: prev.id });
|
|
}
|
|
if (email && (!prev || email.id !== prev.id) && emailHooks.onEmailOpen.size > 0) {
|
|
emailHooks.onEmailOpen.emitSync(emailToReadView(email));
|
|
}
|
|
},
|
|
beginOpenEmail: (row) => {
|
|
const prev = get().selectedEmail;
|
|
openingRow = row;
|
|
set({ selectedEmail: row, lastSelectedEmailKey: emailKeyFor(row), isLoadingEmail: true });
|
|
if (prev && row.id !== prev.id && emailHooks.onEmailClose.size > 0) {
|
|
emailHooks.onEmailClose.emitSync({ emailId: prev.id });
|
|
}
|
|
},
|
|
finishOpenEmail: (email) => {
|
|
const prev = get().selectedEmail;
|
|
const announced = prev !== null && prev !== openingRow;
|
|
openingRow = null;
|
|
set({ selectedEmail: email, lastSelectedEmailKey: emailKeyFor(email), isLoadingEmail: false });
|
|
if (announced && prev.id !== email.id && emailHooks.onEmailClose.size > 0) {
|
|
emailHooks.onEmailClose.emitSync({ emailId: prev.id });
|
|
}
|
|
if ((!announced || prev.id !== email.id) && emailHooks.onEmailOpen.size > 0) {
|
|
emailHooks.onEmailOpen.emitSync(emailToReadView(email));
|
|
}
|
|
},
|
|
abandonOpenEmail: (previous) => {
|
|
const showing = get().selectedEmail;
|
|
const wasRow = showing !== null && showing === openingRow;
|
|
openingRow = null;
|
|
set({ selectedEmail: previous, isLoadingEmail: false });
|
|
// beginOpenEmail announced the close of `previous`; it is open again.
|
|
if (wasRow && previous && showing.id !== previous.id && emailHooks.onEmailOpen.size > 0) {
|
|
emailHooks.onEmailOpen.emitSync(emailToReadView(previous));
|
|
}
|
|
},
|
|
holdListRow: (rowKey) => {
|
|
const { listHold, emails, isScheduledView } = get();
|
|
if (listHold?.rowKey === rowKey) return;
|
|
const keywords = new Map<string, Email['keywords']>();
|
|
for (const email of emails) {
|
|
if (listRowKey(email, isScheduledView) === rowKey) keywords.set(email.id, email.keywords);
|
|
}
|
|
set({ listHold: { rowKey, keywords } });
|
|
},
|
|
selectKeyword: (keyword) => set(state => ({
|
|
selectedKeyword: keyword,
|
|
isLoadingMore: false,
|
|
selectedEmail: null,
|
|
selectedEmailKeys: new Set(),
|
|
expandedThreadIds: new Set(),
|
|
threadEmailsCache: new Map(),
|
|
threadEmailCounts: new Map(),
|
|
viewToken: state.viewToken + 1,
|
|
isUnifiedView: false,
|
|
unifiedRole: null,
|
|
crossView: null,
|
|
})),
|
|
fetchTagCounts: (client) => coalesceRefresh(client, 'tagCounts', async () => {
|
|
try {
|
|
const keywords = useSettingsStore.getState().emailKeywords;
|
|
if (keywords.length === 0) {
|
|
set({ tagCounts: {} });
|
|
return;
|
|
}
|
|
const tagIds = keywords.map(k => k.id);
|
|
// The badge counts what the tag view lists: messages in the own account
|
|
// AND in the group/shared accounts this login reaches (#1038). Sum the
|
|
// per-account counts; an account that fails just contributes nothing.
|
|
// Trash and Junk are left out of both, see fetchTagEmails (#1156).
|
|
const built = buildTagViewAccountClients(client);
|
|
const perAccount = await Promise.allSettled(
|
|
built.map((a) => a.client.getTagCounts(tagIds, a.isShared ? a.accountId : undefined, trashAndJunkIds(a))),
|
|
);
|
|
const counts: Record<string, { total: number; unread: number }> = {};
|
|
for (const outcome of perAccount) {
|
|
if (outcome.status !== 'fulfilled') continue;
|
|
for (const [id, c] of Object.entries(outcome.value)) {
|
|
const cur = counts[id] ?? { total: 0, unread: 0 };
|
|
counts[id] = { total: cur.total + c.total, unread: cur.unread + c.unread };
|
|
}
|
|
}
|
|
set({ tagCounts: counts });
|
|
} catch (error) {
|
|
console.error('Failed to fetch tag counts:', error);
|
|
}
|
|
}),
|
|
selectMailbox: (mailboxId) => set(state => {
|
|
const mailboxes = mailboxesInView(state);
|
|
// The folder's default search scope follows it (Spam and Trash search
|
|
// themselves); a scope the user picked in the dropdown stays. The
|
|
// unread badge's scope follows any real folder.
|
|
const scopeIsDefault = state.searchMailboxId === defaultSearchScopeFor(mailboxes, state.selectedMailbox);
|
|
const scopeFollows = state.searchScopeFollowsFolder && mailboxes.some(m => m.id === mailboxId);
|
|
return {
|
|
selectedMailbox: mailboxId,
|
|
...(scopeFollows
|
|
? { searchMailboxId: mailboxId }
|
|
: scopeIsDefault ? { searchMailboxId: defaultSearchScopeFor(mailboxes, mailboxId) } : {}),
|
|
isLoadingMore: false,
|
|
selectedEmail: null,
|
|
selectedEmailKeys: new Set(),
|
|
selectedKeyword: null,
|
|
expandedThreadIds: new Set(),
|
|
threadEmailsCache: new Map(),
|
|
threadEmailCounts: new Map(),
|
|
isLoadingThread: null,
|
|
viewToken: state.viewToken + 1,
|
|
isUnifiedView: false,
|
|
unifiedRole: null,
|
|
crossView: 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: (email) => {
|
|
const key = emailKeyFor(email);
|
|
const { selectedEmailKeys } = get();
|
|
const newSelection = new Set(selectedEmailKeys);
|
|
if (newSelection.has(key)) {
|
|
newSelection.delete(key);
|
|
} else {
|
|
newSelection.add(key);
|
|
}
|
|
set({ selectedEmailKeys: newSelection, lastSelectedEmailKey: key });
|
|
},
|
|
|
|
setListRows: (rows) => set({ listRows: rows }),
|
|
|
|
selectRangeEmails: (target) => {
|
|
const { emails, lastSelectedEmailKey, selectedEmailKeys, listRows } = get();
|
|
const anchorKey = lastSelectedEmailKey || (emails[0] && emailKeyFor(emails[0]));
|
|
if (!anchorKey) return;
|
|
const targetKeyForRows = emailKeyFor(target);
|
|
if (listRows && listRows.length > 0) {
|
|
const anchorRow = findListRow(listRows, anchorKey);
|
|
const targetRow = findListRow(listRows, targetKeyForRows);
|
|
if (anchorRow !== -1 && targetRow !== -1) {
|
|
const newSelection = new Set(selectedEmailKeys);
|
|
for (let i = Math.min(anchorRow, targetRow); i <= Math.max(anchorRow, targetRow); i++) {
|
|
for (const key of listRows[i].members) newSelection.add(key);
|
|
}
|
|
set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: targetKeyForRows } });
|
|
return;
|
|
}
|
|
}
|
|
const anchorIndex = emails.findIndex(e => emailKeyFor(e) === anchorKey);
|
|
const targetKey = emailKeyFor(target);
|
|
const targetIndex = emails.findIndex(e => emailKeyFor(e) === targetKey);
|
|
if (anchorIndex === -1 || targetIndex === -1) return;
|
|
const start = Math.min(anchorIndex, targetIndex);
|
|
const end = Math.max(anchorIndex, targetIndex);
|
|
const newSelection = new Set(selectedEmailKeys);
|
|
for (let i = start; i <= end; i++) {
|
|
newSelection.add(emailKeyFor(emails[i]));
|
|
}
|
|
set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: targetKey } });
|
|
},
|
|
|
|
extendSelection: (direction) => {
|
|
const { emails, listRows, lastSelectedEmailKey, selectedEmail, selectedEmailKeys, selectionFocus } = get();
|
|
// Walk the rows on screen, as shift-click does; without them, the emails.
|
|
const rows: ListRow[] = listRows && listRows.length > 0
|
|
? listRows
|
|
: emails.map((e) => ({ key: emailKeyFor(e), members: [emailKeyFor(e)] }));
|
|
if (rows.length === 0) return;
|
|
const membersOf = (from: number, to: number) => {
|
|
const keys: string[] = [];
|
|
for (let i = Math.min(from, to); i <= Math.max(from, to); i++) keys.push(...rows[i].members);
|
|
return keys;
|
|
};
|
|
|
|
const anchorKey = lastSelectedEmailKey ?? (selectedEmail ? emailKeyFor(selectedEmail) : null);
|
|
const anchor = anchorKey ? findListRow(rows, anchorKey) : -1;
|
|
if (!anchorKey || anchor === -1) {
|
|
// Nothing to extend from: start with the row at the end the key points from.
|
|
const start = rows[direction > 0 ? 0 : rows.length - 1];
|
|
set({
|
|
selectedEmailKeys: new Set([...selectedEmailKeys, ...start.members]),
|
|
lastSelectedEmailKey: start.key,
|
|
selectionFocus: { anchor: start.key, focus: start.key },
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Continue the range this anchor already has, or begin one at the anchor.
|
|
const previous = selectionFocus && selectionFocus.anchor === anchorKey && selectedEmailKeys.size > 0
|
|
? findListRow(rows, selectionFocus.focus)
|
|
: -1;
|
|
const focus = previous === -1 ? anchor : previous;
|
|
const nextFocus = Math.max(0, Math.min(rows.length - 1, focus + direction));
|
|
|
|
const newSelection = new Set(selectedEmailKeys);
|
|
if (previous !== -1) {
|
|
for (const key of membersOf(anchor, previous)) newSelection.delete(key);
|
|
}
|
|
for (const key of membersOf(anchor, nextFocus)) newSelection.add(key);
|
|
set({ selectedEmailKeys: newSelection, selectionFocus: { anchor: anchorKey, focus: rows[nextFocus].key } });
|
|
},
|
|
|
|
selectAllEmails: () => {
|
|
const { emails } = get();
|
|
set({ selectedEmailKeys: new Set(emails.map(emailKeyFor)) });
|
|
},
|
|
|
|
clearSelection: () => {
|
|
set({ selectedEmailKeys: new Set(), lastSelectedEmailKey: null });
|
|
},
|
|
|
|
// JMAP operations
|
|
fetchMailboxes: (client) => coalesceRefresh(client, 'mailboxes', async () => {
|
|
// An account switch while this is in flight empties the stores for the
|
|
// next account; this account's folders must not land in them.
|
|
const epoch = currentStoreEpoch();
|
|
const stale = () => currentStoreEpoch() !== epoch;
|
|
// 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 {
|
|
// Prefer the state-reporting variant so a later Mailbox push can be
|
|
// resolved as a delta (applyMailboxDelta) instead of this full refetch.
|
|
const fetched = client.getAllMailboxesWithState
|
|
? await client.getAllMailboxesWithState()
|
|
: { mailboxes: await client.getAllMailboxes(), states: {} };
|
|
if (stale()) return;
|
|
const mailboxes = fetched.mailboxes;
|
|
|
|
// Guard against a transient fetch returning an empty list (e.g. a server
|
|
// concurrent-request limit hit during a burst of deletes). Replacing a
|
|
// populated folder list with [] leaves the sidebar stuck on "Loading
|
|
// mailboxes..." until the next successful refetch. Keep what we have.
|
|
if (mailboxes.length === 0 && !isInitialLoad) {
|
|
return;
|
|
}
|
|
|
|
// Publish the existing mailbox-refresh extension hook after a successful
|
|
// fetch. This is also emitted for Mailbox-only state changes (for example
|
|
// an empty provider label), where no Email fetch follows.
|
|
if (emailHooks.onMailboxesRefresh.size > 0) await emailHooks.onMailboxesRefresh.emit(mailboxes.map(mailboxToView));
|
|
if (stale()) return;
|
|
|
|
// 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
|
|
// Unified per-role views (All Inbox/Drafts/Junk/…) and cross-account views
|
|
// (All unread/starred/all) use a virtual id not present in the fetched
|
|
// list. A background refresh after a delete must not clobber it and jump
|
|
// the user back to the inbox.
|
|
|| isUnifiedMailboxId(currentSelectedMailbox)
|
|
|| isCrossViewId(currentSelectedMailbox)
|
|
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
|
|
// A first load with folders leaves isLoading on: the first page is
|
|
// fetched next and turns it off. Dropping it here showed "No emails"
|
|
// between the two, and a new skeleton after it.
|
|
const loadingPatch = isInitialLoad && mailboxes.length === 0
|
|
? { isLoading: false, mailboxSyncStates: fetched.states }
|
|
: { mailboxSyncStates: fetched.states };
|
|
if (!selectionValid) {
|
|
// A search scope naming a folder that is gone (e.g. the Spam/Trash
|
|
// the default scope followed) falls back to the default.
|
|
const scope = get().searchMailboxId;
|
|
const scopePatch = !isAllFoldersSearchScope(scope) && !mailboxes.some(m => m.id === scope)
|
|
? { searchMailboxId: '' }
|
|
: {};
|
|
// 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, ...scopePatch, ...loadingPatch });
|
|
} else {
|
|
set({ mailboxes, selectedMailbox: '', ...scopePatch, ...loadingPatch });
|
|
}
|
|
} else {
|
|
set({ mailboxes, ...loadingPatch });
|
|
}
|
|
} catch (error) {
|
|
if (stale()) return;
|
|
// The existing folder list is deliberately left untouched: a failed
|
|
// background refresh must never blank the sidebar (#780).
|
|
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>; __initialDataLoaded?: boolean };
|
|
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);
|
|
}
|
|
target.__initialDataLoaded = get().mailboxes.length > 0;
|
|
// 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, opts) => {
|
|
const isCurrentView = captureEmailListView();
|
|
// A background refresh (e.g. after an account switch restored a cached list)
|
|
// repopulates the list without showing the loading overlay, so switching to
|
|
// an already-visited account doesn't flash a spinner over the visible mail.
|
|
let background = opts?.background ?? false;
|
|
// Loading a real mailbox is a fresh navigation (leaving any cross view), so
|
|
// drop the unread/starred retain set and the held row.
|
|
set(background ? { error: null, retainedInViewIds: new Set(), listHold: null } : { isLoading: true, error: null, retainedInViewIds: new Set(), listHold: 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;
|
|
}
|
|
|
|
// Unified / cross-account views use client-only virtual mailbox ids
|
|
// (`__cross_*`, `__unified_*`) that no JMAP server accepts as `inMailbox`.
|
|
// Route them through the fan-out loaders (mirrors loadMoreEmails /
|
|
// refreshCurrentMailbox) instead of sending the virtual id to getEmails,
|
|
// which the server rejects and the catch below then empties the list (#791).
|
|
if (isCrossViewId(targetMailboxId) || isUnifiedMailboxId(targetMailboxId)) {
|
|
const { crossView, unifiedRole, searchQuery, searchFilters } = get();
|
|
// View state not resolved yet: don't send a virtual id and don't wipe
|
|
// the current list - just stop the loading state.
|
|
if (!crossView && !unifiedRole) {
|
|
set({ isLoading: false });
|
|
return;
|
|
}
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
const hasFilters = !isFilterEmpty(searchFilters);
|
|
const result = crossView
|
|
? (hasFilters
|
|
? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0)
|
|
: searchQuery
|
|
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0)
|
|
: await fetchCrossViewEmails(built, crossView, emailsPerPage, 0))
|
|
: (hasFilters
|
|
? await advancedSearchUnifiedEmails(built, unifiedRole!, (mb) => buildJMAPFilter(searchQuery, searchFilters, mb), emailsPerPage, 0)
|
|
: searchQuery
|
|
? await searchUnifiedEmails(built, unifiedRole!, searchQuery, emailsPerPage, 0)
|
|
: await fetchUnifiedEmails(built, unifiedRole!, emailsPerPage, 0, getMessageListOrderFor(unifiedRole)));
|
|
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
if (!isCurrentView()) return;
|
|
set({
|
|
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
listOrder: crossView || hasFilters || searchQuery ? [] : getMessageListOrderFor(unifiedRole),
|
|
// Merged from several accounts with the client comparator.
|
|
listKeepsServerTextOrder: false,
|
|
emailListSync: null,
|
|
threadEmailsCache: new Map(),
|
|
expandedThreadIds: new Set(),
|
|
isLoadingThread: null,
|
|
isLoading: false,
|
|
unifiedErrors: result.errors,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Get emails per page from settings
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
|
|
// A tag view takes precedence over the selected folder. Tags span
|
|
// folders AND accounts: the same keyword sits on messages in the user's
|
|
// own account and in the group/shared accounts they can reach, so fan
|
|
// out over all of them instead of querying only the account of the
|
|
// folder that happened to be selected (#1038). A tag view has no role,
|
|
// so it only follows the list order under the "all folders" scope (#718).
|
|
const { selectedKeyword } = get();
|
|
if (selectedKeyword) {
|
|
const order = getMessageListOrderFor(null);
|
|
const built = buildTagViewAccountClients(client);
|
|
// Selecting a tag keeps an active search (`handleTagSelect` does not
|
|
// clear it), and the search paths narrow the tag rather than replace
|
|
// it — so honour the query here too, or the list would flip between
|
|
// narrowed and whole depending on which path last ran.
|
|
const { searchQuery: tagQuery, searchFilters: tagFilters } = get();
|
|
const tagFilter = !isFilterEmpty(tagFilters)
|
|
? buildJMAPFilter(tagQuery, tagFilters, undefined)
|
|
: tagQuery
|
|
? textFilter(tagQuery)
|
|
: undefined;
|
|
const result = await fetchTagEmails(built, `$label:${selectedKeyword}`, emailsPerPage, 0, order, tagFilter);
|
|
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
if (!isCurrentView()) return;
|
|
set({
|
|
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
listOrder: order,
|
|
// Merged from several accounts with the client comparator.
|
|
listKeepsServerTextOrder: false,
|
|
// A server-side keyword filter cannot be delta-synced.
|
|
emailListSync: null,
|
|
threadEmailsCache: new Map(),
|
|
expandedThreadIds: new Set(),
|
|
isLoadingThread: null,
|
|
isLoading: false,
|
|
unifiedErrors: result.errors,
|
|
});
|
|
void get().fetchThreadEmailCounts(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;
|
|
|
|
// Plugin-registered category tabs (Gmail-style) AND their resolved JMAP
|
|
// filter fragment into the mailbox view, and the "Unread first" inbox
|
|
// leaves its unread mail to the section above the list.
|
|
const categoryFilter = folderListFilter(mailbox, get());
|
|
const unreadFirst = unreadFirstFor(mailbox, get());
|
|
const sectionLoad = unreadFirst && mailbox
|
|
? loadUnreadSection(client, mailbox, get).catch((error) => {
|
|
console.error('Failed to fetch the unread section:', error);
|
|
return null;
|
|
})
|
|
: null;
|
|
const showSection = async () => {
|
|
if (!sectionLoad) return;
|
|
const loaded = await sectionLoad;
|
|
if (loaded && isCurrentView()) applyUnreadSection(get, set, targetMailboxId, loaded);
|
|
};
|
|
|
|
// The configured list order (#718).
|
|
const order = getMessageListOrderFor(mailbox?.role);
|
|
|
|
// Paint what this folder showed last time and load behind it: the rows
|
|
// are there at once, the fresh page replaces them when it lands, and a
|
|
// failed load leaves them in place instead of emptying the folder.
|
|
const listKey = folderListKey({
|
|
login: useAuthStore.getState().activeAccountId,
|
|
viewingAccountId: get().viewingAccountId,
|
|
// The login tells own accounts apart; a shared folder adds its owner.
|
|
accountId: accountId ?? '',
|
|
mailboxId: targetMailboxId,
|
|
filter: categoryFilter,
|
|
order,
|
|
});
|
|
const remembered = background ? undefined : rememberedFolderList(listKey);
|
|
if (remembered) {
|
|
background = true;
|
|
set({
|
|
emails: annotateScheduledEmails(remembered.emails, get().scheduledSubmissionByEmailId),
|
|
hasMoreEmails: remembered.hasMoreEmails,
|
|
totalEmails: remembered.totalEmails,
|
|
listOrder: order,
|
|
// The state the page was current at, if it was a plain folder list:
|
|
// a push delta, and the catch-up below, start from it.
|
|
emailListSync: remembered.sync?.mailboxId === targetMailboxId ? remembered.sync : null,
|
|
threadEmailCounts: remembered.threadEmailCounts ?? new Map(),
|
|
threadEmailsCache: new Map(),
|
|
expandedThreadIds: new Set(),
|
|
isLoadingThread: null,
|
|
isLoading: false,
|
|
// The remembered page holds the section's rows too; they are its unread ones.
|
|
unreadSection: unreadFirst ? sectionFromUnreadRows(remembered.emails, targetMailboxId) : null,
|
|
});
|
|
} else if (!unreadFirst) {
|
|
set({ unreadSection: null });
|
|
}
|
|
// Until this folder's rows are on screen, edits to the list belong to
|
|
// whatever was showing before and must not be remembered under this key.
|
|
shownFolderList = remembered ? { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage } : null;
|
|
|
|
// A background refresh of rows already on screen for this folder, in
|
|
// this order (an account switch or boot restoring its snapshot), can
|
|
// start from their baseline too instead of replacing every row.
|
|
const shownSync = get().emailListSync;
|
|
const refreshesShownRows = background && !remembered && !!shownSync
|
|
&& shownSync.mailboxId === targetMailboxId
|
|
&& JSON.stringify(get().listOrder) === JSON.stringify(order);
|
|
if (refreshesShownRows) shownFolderList = { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage };
|
|
|
|
// A remembered page with a baseline only needs what changed since
|
|
// (Email/changes): usually nothing, and then the rows stay as they are
|
|
// instead of being re-read and rendered again. Anything the delta
|
|
// cannot settle falls through to the full page below.
|
|
if ((remembered || refreshesShownRows) && get().emailListSync && !categoryFilter) {
|
|
const upToDate = await applyEmailDeltaNow(client, null, get, set);
|
|
if (!isCurrentView()) return;
|
|
if (upToDate) {
|
|
await showSection();
|
|
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
|
|
// The boot snapshot carries no conversation sizes.
|
|
if (get().threadEmailCounts.size === 0) void get().fetchThreadEmailCounts(client);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const result = await effectiveClient.getEmails(
|
|
jmapMailboxId,
|
|
accountId,
|
|
emailsPerPage,
|
|
0,
|
|
undefined,
|
|
true,
|
|
categoryFilter ?? undefined,
|
|
order,
|
|
);
|
|
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
if (!isCurrentView()) return;
|
|
// Only a plain folder list can be delta-synced; category views filter
|
|
// server-side, and a delta cannot tell which changed rows would match
|
|
// that filter.
|
|
const emailListSync: EmailListSync | null =
|
|
result.state && !categoryFilter
|
|
? {
|
|
state: result.state,
|
|
accountId: accountId ?? effectiveClient.getAccountId(),
|
|
mailboxId: targetMailboxId,
|
|
}
|
|
: null;
|
|
const threadCounts = threadCountsPatch(enrichedEmails, result.threadCounts, get().threadEmailCounts);
|
|
set({
|
|
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
listOrder: order,
|
|
listKeepsServerTextOrder: effectiveClient.keepsServerTextOrder?.(accountId) ?? false,
|
|
emailListSync,
|
|
// Clear thread caches since the email list was fully replaced
|
|
threadEmailsCache: new Map(),
|
|
expandedThreadIds: new Set(),
|
|
isLoadingThread: null,
|
|
...threadCounts,
|
|
isLoading: false
|
|
});
|
|
shownFolderList = { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage };
|
|
await showSection();
|
|
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
|
|
// Only a server that did not answer the chained Thread/get needs a
|
|
// request of its own for the conversation sizes.
|
|
if (!('threadEmailCounts' in threadCounts)) void get().fetchThreadEmailCounts(client);
|
|
} catch (error) {
|
|
if (!isCurrentView()) return;
|
|
console.error('Failed to fetch emails:', error);
|
|
// A failed background refresh must not wipe the list it was refreshing -
|
|
// keep the restored/prefetched emails visible and just surface the error.
|
|
if (background) {
|
|
set({ error: error instanceof Error ? error.message : "Failed to fetch emails" });
|
|
} else {
|
|
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, crossView } = get();
|
|
const isCurrentView = captureEmailListView();
|
|
|
|
// Don't load if already loading or no more emails
|
|
if (isLoadingMore || !hasMoreEmails) return;
|
|
|
|
// Cross-account views fan out across all accounts' included folders. Paginate
|
|
// via the cross-view loader (search-aware), mirroring the unified branch.
|
|
if (isUnifiedView && crossView) {
|
|
set({ isLoadingMore: true, error: null });
|
|
try {
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const position = positionsByAccount(emails);
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
const hasFilters = !isFilterEmpty(get().searchFilters);
|
|
const result = hasFilters
|
|
? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, get().searchFilters, undefined), emailsPerPage, position)
|
|
: searchQuery
|
|
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
|
|
: await fetchCrossViewEmails(built, crossView, 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));
|
|
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
|
|
if (!isCurrentView()) return;
|
|
set({
|
|
emails: [...currentEmails, ...enrichedNewEmails],
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
isLoadingMore: false,
|
|
unifiedErrors: result.errors,
|
|
});
|
|
} catch (error) {
|
|
if (!isCurrentView()) return;
|
|
console.error('Failed to load more cross-account emails:', error);
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to load more emails",
|
|
isLoadingMore: false,
|
|
});
|
|
}
|
|
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 = positionsByAccount(emails);
|
|
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, getMessageListOrderFor(unifiedRole));
|
|
const currentEmails = get().emails;
|
|
const existingIds = new Set(currentEmails.map(e => e.id));
|
|
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
|
|
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
|
|
if (!isCurrentView()) return;
|
|
set({
|
|
emails: [...currentEmails, ...enrichedNewEmails],
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
isLoadingMore: false,
|
|
unifiedErrors: result.errors,
|
|
});
|
|
} catch (error) {
|
|
if (!isCurrentView()) return;
|
|
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) && selectedKeyword) {
|
|
// Searching inside a tag view narrows the tag (see searchEmails), so
|
|
// page 2 must carry the same keyword AND the same query — otherwise it
|
|
// appends unrelated all-folders hits to a tag list.
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await fetchTagEmails(
|
|
built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null),
|
|
hasFilters
|
|
? buildJMAPFilter(searchQuery, searchFilters, undefined)
|
|
: textFilter(searchQuery),
|
|
);
|
|
set({ unifiedErrors: result.errors });
|
|
} else if (searchQuery || hasFilters) {
|
|
// Paginate with the same scope the search itself ran under, not the
|
|
// folder that happens to be open in the list.
|
|
const { searchMailboxId } = get();
|
|
if (isAllFoldersSearchScope(searchMailboxId)) {
|
|
// Folder-less scope: the next page of the same own + group account
|
|
// fan-out the search ran (#1082).
|
|
const built = buildTagViewAccountClients(client);
|
|
const scope = acrossAccountsSearchOptions(searchMailboxId);
|
|
result = hasFilters
|
|
? await advancedSearchAcrossAccounts(built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, positionsByAccount(emails), scope)
|
|
: await searchAcrossAccounts(built, searchQuery, emailsPerPage, positionsByAccount(emails), scope);
|
|
set({ unifiedErrors: result.errors });
|
|
} else {
|
|
const mailboxes = resolveActionMailboxes();
|
|
const mailbox = mailboxes.find(mb => mb.id === searchMailboxId);
|
|
const jmapMailboxId = mailbox?.originalId || searchMailboxId;
|
|
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 if (selectedKeyword) {
|
|
// Tag view: the next page of the same cross-account fan-out the
|
|
// initial fetch ran (same keyword, no folder constraint, same order),
|
|
// so pagination spans the own and group accounts alike (#1038).
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await fetchTagEmails(
|
|
built, `$label:${selectedKeyword}`, emailsPerPage, positionsByAccount(emails), getMessageListOrderFor(null),
|
|
);
|
|
set({ unifiedErrors: result.errors });
|
|
} 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;
|
|
|
|
// Category tabs (plugin-registered) must filter pagination the same
|
|
// way as the initial fetch or pages would mix categories.
|
|
const categoryFilter = folderListFilter(mailbox, get());
|
|
// Rows kept out of a keyword order (the open conversation, see
|
|
// refreshCurrentMailbox) are not part of the loaded head of the
|
|
// server order; counting them would skip as many rows. Neither are
|
|
// the rows of the Unread section.
|
|
const retained = get().retainedInViewIds;
|
|
const outOfOrder = emails.filter(e => retained.has(e.id)).length
|
|
+ unreadSectionRowCount(emails, get().unreadSection);
|
|
result = await effectiveClient.getEmails(
|
|
jmapMailboxId,
|
|
accountId,
|
|
emailsPerPage,
|
|
position - outOfOrder,
|
|
undefined,
|
|
true,
|
|
categoryFilter ?? undefined,
|
|
getMessageListOrderFor(mailbox?.role),
|
|
);
|
|
// A kept row this page reached is back in order, and so is a row
|
|
// of the Unread section that was read since.
|
|
const reached = new Set(result.emails.map(e => e.id));
|
|
if (outOfOrder > 0 && [...retained].some(id => reached.has(id))) {
|
|
set({ retainedInViewIds: new Set([...get().retainedInViewIds].filter(id => !reached.has(id))) });
|
|
}
|
|
const unreadSection = withoutReached(get().unreadSection, reached);
|
|
if (unreadSection !== get().unreadSection) set({ unreadSection });
|
|
}
|
|
|
|
// 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));
|
|
|
|
const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails);
|
|
if (!isCurrentView()) return;
|
|
const threadCounts = threadCountsPatch(
|
|
enrichedNewEmails, 'threadCounts' in result ? result.threadCounts : undefined, get().threadEmailCounts,
|
|
);
|
|
set({
|
|
emails: [...currentEmails, ...enrichedNewEmails],
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
...threadCounts,
|
|
isLoadingMore: false
|
|
});
|
|
// Thread counts for the new rows, unless the page brought them along.
|
|
if (enrichedNewEmails.length > 0 && !('threadEmailCounts' in threadCounts)) {
|
|
void get().fetchThreadEmailCounts(client);
|
|
}
|
|
} catch (error) {
|
|
if (!isCurrentView()) return;
|
|
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 {
|
|
// Route to the owning account. In aggregate views (All Mail, unified,
|
|
// cross-account) the selected mailbox is virtual, so derive the client +
|
|
// accountId from the email itself (handles shared/group accounts); fall
|
|
// back to the selected-mailbox shared-folder logic for normal views.
|
|
const selected = get().selectedEmail;
|
|
const listEmail = selected?.id === emailId ? selected : get().emails.find(e => e.id === emailId);
|
|
const { client: actionClient, accountId } = resolveEmailBlobContext(listEmail ?? null, client);
|
|
if (!actionClient) throw new Error('No connected client for email source');
|
|
|
|
const email = await actionClient.getEmail(emailId, accountId);
|
|
|
|
if (email) {
|
|
// Email/get does not return client-only source metadata. Keep the source
|
|
// captured before the await, including automatic selection after actions.
|
|
// Only carry stamps the list row has. A direct-folder row stays
|
|
// unstamped, since threadKeyFor scopes keys by these stamps.
|
|
const annotatedEmail = annotateScheduledEmail({
|
|
...email,
|
|
sourceClientAccountId: listEmail?.sourceClientAccountId,
|
|
sourceAccountId: listEmail?.sourceAccountId,
|
|
}, get().scheduledSubmissionByEmailId);
|
|
annotatedEmail.accountId = listEmail?.accountId;
|
|
annotatedEmail.accountLabel = listEmail?.accountLabel;
|
|
annotatedEmail.sourceFolder = listEmail?.sourceFolder;
|
|
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, options) => {
|
|
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, options);
|
|
// Each send leaves a submission record; sweep the ones that can go
|
|
// (throttled per account by the client).
|
|
void client.pruneEmailSubmissions();
|
|
// Refresh handled by UI layer for immediate feedback
|
|
set({
|
|
isLoading: false,
|
|
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
|
? {
|
|
submissionId: result.emailSubmissionId,
|
|
emailId: result.emailId,
|
|
identityId,
|
|
sendAt: result.sendAt,
|
|
isSmime: false,
|
|
submissionAccountId: result.submissionAccountId,
|
|
// Cross-account send: undo/send-now must talk to the account that
|
|
// holds the submission, not the active one (#461).
|
|
localAccountId: options?.localAccountId,
|
|
}
|
|
: 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);
|
|
void client.pruneEmailSubmissions();
|
|
set({
|
|
isLoading: false,
|
|
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
|
|
? { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: true, submissionAccountId: result.submissionAccountId }
|
|
: 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, emailKey): Promise<boolean> => {
|
|
try {
|
|
// Get the email to check if it's unread and which mailboxes it belongs to
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
const email = get().emails.find(isTarget);
|
|
if (!email) return false;
|
|
|
|
const isUnread = !email.keywords?.$seen;
|
|
// In unified view route to the email's own account (client + that
|
|
// account's mailbox list); otherwise the active/viewing context. (#281)
|
|
const { client: effectiveClient, mailboxes, accountId } = resolveEmailActionContext(email, client);
|
|
|
|
// Get delete action preference from settings
|
|
const deleteAction = useSettingsStore.getState().deleteAction;
|
|
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
|
|
|
// The email's current mailbox drives the junk auto-permanent-delete rule.
|
|
// In unified view it comes from the email's own folders (matching the
|
|
// unified role), not the active account's selected mailbox.
|
|
const currentMailbox = isAggregateListView()
|
|
? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole)
|
|
?? mailboxes.find(mb => emailInMailbox(email, mb)))
|
|
: mailboxes.find(mb => mb.id === get().selectedMailbox);
|
|
|
|
// If in junk folder and setting is enabled, permanently delete
|
|
const isInJunk = currentMailbox?.role === 'junk';
|
|
if (isInJunk && permanentlyDeleteJunk) {
|
|
forceDelete = true;
|
|
}
|
|
|
|
const permanent = !((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete);
|
|
if (emailHooks.onBeforeEmailDelete.size > 0
|
|
&& !(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [emailId], permanent }))) {
|
|
return false;
|
|
}
|
|
|
|
// 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).
|
|
// Counter changes go to the email's own account list (#281). Source and
|
|
// trash live in the same account.
|
|
set((state) => {
|
|
const mailboxPatch = email.mailboxIds
|
|
? applyMailboxCounterUpdate(state, email, (mailbox) => {
|
|
if (emailInMailbox(email, mailbox)) {
|
|
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 === 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 => !isTarget(e)),
|
|
selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
|
|
...mailboxPatch,
|
|
};
|
|
});
|
|
void emailHooks.onAfterEmailDelete.emit({ emailIds: [emailId], permanent: false });
|
|
return true;
|
|
}
|
|
// 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 - in the email's own account, like the batch path.
|
|
await effectiveClient.deleteEmail(emailId, accountId);
|
|
|
|
// Remove from local state and update mailbox counters (in the email's own
|
|
// account list). Unread emails also decrement the unread counters. (#281)
|
|
set((state) => {
|
|
const mailboxPatch = email.mailboxIds
|
|
? applyMailboxCounterUpdate(state, email, (mailbox) =>
|
|
emailInMailbox(email, mailbox)
|
|
? {
|
|
...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,
|
|
}
|
|
: mailbox)
|
|
: {};
|
|
|
|
return {
|
|
emails: state.emails.filter(e => !isTarget(e)),
|
|
selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
|
|
...mailboxPatch,
|
|
};
|
|
});
|
|
void emailHooks.onAfterEmailDelete.emit({ emailIds: [emailId], permanent: true });
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to delete email"
|
|
});
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
markAsRead: async (client, emailId, read, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
try {
|
|
// Check if this email is already being processed
|
|
const processingKey = `${emailKey ?? emailId}-${read}`;
|
|
const currentProcessing = get().processingReadStatus;
|
|
if (currentProcessing.has(processingKey)) {
|
|
return true; // Already being processed
|
|
}
|
|
|
|
// Get the email to check its current state and mailboxes
|
|
const email = get().emails.find(isTarget);
|
|
if (!email) return false;
|
|
|
|
// Check if already in the desired state
|
|
const isCurrentlyRead = email.keywords?.$seen;
|
|
if (isCurrentlyRead === read) {
|
|
return true; // Already in desired state
|
|
}
|
|
|
|
// Add to processing set
|
|
set((state) => ({
|
|
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
|
|
}));
|
|
|
|
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
|
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [emailId], read }))) {
|
|
set((state) => {
|
|
const next = new Set(state.processingReadStatus);
|
|
next.delete(processingKey);
|
|
return { processingReadStatus: next };
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// In unified view route to the email's own account client; otherwise use
|
|
// the active/viewing client and the shared-folder accountId. (#281)
|
|
const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
|
|
|
|
await actionClient.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(isTarget);
|
|
if (!emailInState) return { processingReadStatus: newProcessingSet };
|
|
|
|
const wasRead = emailInState.keywords?.$seen;
|
|
if (wasRead === read) {
|
|
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
|
|
}
|
|
|
|
// Adjust the unread counter on the folder(s) holding this email, in the
|
|
// email's *own* account's mailbox list (#281). -1 marking read, +1 unread.
|
|
const delta = read ? -1 : 1;
|
|
const mailboxPatch = applyMailboxCounterUpdate(state, emailInState, (mailbox) =>
|
|
emailInMailbox(emailInState, mailbox)
|
|
? {
|
|
...mailbox,
|
|
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
|
|
unreadThreads: Math.max(0, mailbox.unreadThreads + delta),
|
|
}
|
|
: mailbox,
|
|
);
|
|
|
|
// In the unread cross view, a mail just marked read no longer matches the
|
|
// filter. Retain it so the row stays put (status + counter update only)
|
|
// rather than vanishing on the next push refresh; marking it unread again
|
|
// makes it match, so drop the retention.
|
|
const retainedInViewIds = updateRetainedForView(
|
|
state.retainedInViewIds,
|
|
retainingCrossView(state) === 'unread' ? (read ? 'add' : 'remove') : 'none',
|
|
emailId,
|
|
);
|
|
|
|
return {
|
|
emails: state.emails.map(e =>
|
|
isTarget(e) ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
|
|
),
|
|
selectedEmail: (!!state.selectedEmail && isTarget(state.selectedEmail))
|
|
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
|
|
: state.selectedEmail,
|
|
retainedInViewIds,
|
|
...mailboxPatch,
|
|
// Same delta, applied to every tag this email carries, so the sidebar
|
|
// tag badges track the folder counters instead of going stale.
|
|
tagCounts: applyTagCountReadDelta(state.tagCounts, [
|
|
{ keywords: emailInState.keywords, delta },
|
|
]),
|
|
processingReadStatus: newProcessingSet,
|
|
// Also update threadEmailsCache so expanded dropdowns reflect the change
|
|
threadEmailsCache: (() => {
|
|
let updated = false;
|
|
const newCache = new Map(state.threadEmailsCache);
|
|
for (const [tid, cachedEmails] of newCache) {
|
|
const idx = cachedEmails.findIndex(isTarget);
|
|
if (idx !== -1) {
|
|
newCache.set(tid, cachedEmails.map((e, i) =>
|
|
i === idx ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
|
|
));
|
|
updated = true;
|
|
break;
|
|
}
|
|
}
|
|
return updated ? newCache : state.threadEmailsCache;
|
|
})(),
|
|
};
|
|
});
|
|
void emailHooks.onEmailReadStateChange.emit({ emailIds: [emailId], read });
|
|
refillAfterKeywordChange(get, client, ['$seen']);
|
|
} 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;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
moveToMailboxCrossAware: async (client, emailId, destinationMailboxId, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
const state = get();
|
|
const email = state.emails.find(isTarget);
|
|
if (!email) return false;
|
|
|
|
// A list that mixes accounts offers the viewed account's folders, and
|
|
// the message may be in any account (moveSourceBatches). Its own folder
|
|
// list would take the id for one of that account's folders.
|
|
if (isAggregateListView() && email.sourceAccountId) {
|
|
const hookMailboxes = resolveActionMailboxes();
|
|
const move = { emailIds: [email.id], fromMailboxId: moveSourceMailboxId([email], hookMailboxes), toMailboxId: destinationMailboxId };
|
|
if (!(await allowEmailMove(move, hookMailboxes))) return false;
|
|
const failures = await moveRowsToMailbox([email], resolveMoveDestination(client, destinationMailboxId), client);
|
|
if (failures.length > 0) throw failures[0].error;
|
|
emitEmailMove(move, hookMailboxes);
|
|
return true;
|
|
}
|
|
|
|
const { mailboxes } = resolveEmailActionContext(email, client);
|
|
const find = (id: string) =>
|
|
mailboxes.find((mb) => mb.id === id) ?? state.mailboxes.find((mb) => mb.id === id);
|
|
const destMailbox = find(destinationMailboxId);
|
|
// A context-menu move acts on the visible list, so the source folder is the
|
|
// one currently open.
|
|
const sourceMailbox = find(state.selectedMailbox ?? '');
|
|
|
|
// Cross-account when the two folders live in different JMAP accounts (both
|
|
// own and shared mailboxes carry accountId, so this catches own↔shared too).
|
|
const isCrossAccount =
|
|
!!destMailbox &&
|
|
!!sourceMailbox?.accountId &&
|
|
!!destMailbox.accountId &&
|
|
sourceMailbox.accountId !== destMailbox.accountId;
|
|
|
|
if (!isCrossAccount) {
|
|
return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey);
|
|
}
|
|
|
|
const destAccountId = resolveDestLocalAccountId(destMailbox!);
|
|
const sourceAccountId =
|
|
email.accountId ?? state.viewingAccountId ?? useAuthStore.getState().activeAccountId;
|
|
if (!destAccountId || !sourceAccountId) {
|
|
// Can't resolve the local endpoints — fall back rather than drop the mail.
|
|
return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey);
|
|
}
|
|
|
|
// JMAP has no cross-account move: copy the raw message into the destination
|
|
// account's mailbox, then delete the original (crossAccountMoveEmails). The
|
|
// *Jmap* overrides target the owner account when a shared folder is reached
|
|
// through another user's client.
|
|
return await get().crossAccountMoveEmails(
|
|
new Map([[sourceAccountId, [emailId]]]),
|
|
destAccountId,
|
|
destMailbox!.originalId ?? destMailbox!.id,
|
|
destMailbox!.isShared ? destMailbox!.accountId : undefined,
|
|
sourceMailbox?.isShared ? sourceMailbox.accountId : undefined,
|
|
);
|
|
},
|
|
|
|
moveToMailbox: async (client, emailId, destinationMailboxId, emailKey): Promise<boolean> => {
|
|
try {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
const email = get().emails.find(isTarget);
|
|
if (!email) return false;
|
|
|
|
const isUnread = !email.keywords?.$seen;
|
|
|
|
// In unified view route to the email's own account (client + that
|
|
// account's mailbox list, where the destination id lives); otherwise the
|
|
// active/viewing context. (#281)
|
|
const { client: actionClient, mailboxes, accountId } = resolveEmailActionContext(email, client);
|
|
|
|
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
|
|
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
|
|
|
const move = { emailIds: [emailId], fromMailboxId: moveSourceMailboxId([email], mailboxes), toMailboxId: destinationMailboxId };
|
|
if (!(await allowEmailMove(move, mailboxes))) return false;
|
|
|
|
await actionClient.moveEmail(emailId, jmapDestId, accountId);
|
|
|
|
set((state) => {
|
|
// Counter changes go to the email's own account list; source and
|
|
// destination live in the same account. (#281)
|
|
const mailboxPatch = applyMailboxCounterUpdate(state, email, (mailbox) => {
|
|
if (emailInMailbox(email, mailbox)) {
|
|
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 => !isTarget(e)),
|
|
selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
|
|
...mailboxPatch,
|
|
};
|
|
});
|
|
emitEmailMove(move, mailboxes);
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to move email"
|
|
});
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
moveEmailsToMailbox: async (client, emailIds, destinationMailboxId, destLocalAccountId, rows): Promise<boolean> => {
|
|
if (emailIds.length === 0) return true;
|
|
const idSet = new Set(emailIds);
|
|
|
|
if (isAggregateListView()) {
|
|
// Emails may span logins and accounts: each group changes folders in
|
|
// place only in the folder's own account (moveSourceBatches). Rows
|
|
// that could not be moved stay. The rows a drag names, not every listed
|
|
// row with one of their ids: another account's message can share it.
|
|
const moving = rows?.length ? rows.filter(e => idSet.has(e.id)) : get().emails.filter(e => idSet.has(e.id));
|
|
const hookMailboxes = resolveActionMailboxes();
|
|
const move = { emailIds: [...idSet], fromMailboxId: moveSourceMailboxId(moving, hookMailboxes), toMailboxId: destinationMailboxId };
|
|
if (!(await allowEmailMove(move, hookMailboxes))) return false;
|
|
const failures = await moveRowsToMailbox(
|
|
moving,
|
|
resolveMoveDestination(client, destinationMailboxId, destLocalAccountId),
|
|
client,
|
|
);
|
|
const failed = new Set(failures.flatMap(f => f.ids));
|
|
const movedIds = move.emailIds.filter(id => !failed.has(id));
|
|
if (movedIds.length > 0) emitEmailMove({ ...move, emailIds: movedIds }, hookMailboxes);
|
|
if (failures.length > 0) {
|
|
const error = new Error(batchFailureMessage(failures, 'move'));
|
|
set({ error: error.message });
|
|
throw error;
|
|
}
|
|
return true;
|
|
}
|
|
// moveToMailbox acts on a listed row only; an id from elsewhere (a
|
|
// plugin moving new mail) takes the batch path, or it was dropped.
|
|
if (emailIds.length === 1 && get().emails.some(e => e.id === emailIds[0])) {
|
|
return await get().moveToMailbox(client, emailIds[0], destinationMailboxId);
|
|
}
|
|
|
|
try {
|
|
const { emails, selectedMailbox } = get();
|
|
const mailboxes = resolveActionMailboxes();
|
|
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
|
|
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
|
const affected = emails.filter(e => idSet.has(e.id));
|
|
const move = { emailIds: [...idSet], fromMailboxId: moveSourceMailboxId(affected, mailboxes), toMailboxId: destinationMailboxId };
|
|
if (!(await allowEmailMove(move, mailboxes))) return false;
|
|
|
|
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;
|
|
for (const e of affected) {
|
|
if (!e.keywords?.$seen) unreadDelta += 1;
|
|
}
|
|
const movedCount = affected.length;
|
|
|
|
set((state) => {
|
|
// Decrement source folders per the email's own account list (#281).
|
|
const patch = applyBatchMailboxCounterUpdate(state, affected, applyMoveOutCounters);
|
|
// The destination folder (picked from the active/viewing list) gains them.
|
|
patch.mailboxes = patch.mailboxes.map(mb => mb.id === destinationMailboxId
|
|
? { ...mb, totalEmails: mb.totalEmails + movedCount, unreadEmails: mb.unreadEmails + unreadDelta }
|
|
: mb);
|
|
return {
|
|
emails: state.emails.filter(e => !idSet.has(e.id)),
|
|
selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail,
|
|
selectedEmailKeys: (() => {
|
|
const next = new Set(state.selectedEmailKeys);
|
|
// Keys, not ids: drop exactly the emails leaving the list.
|
|
for (const e of state.emails) if (idSet.has(e.id)) next.delete(emailKeyFor(e));
|
|
return next;
|
|
})(),
|
|
...patch,
|
|
};
|
|
});
|
|
emitEmailMove(move, mailboxes);
|
|
} catch (error) {
|
|
set({ error: error instanceof Error ? error.message : 'Failed to move emails' });
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
removeFromMailbox: async (client, targets): Promise<boolean> => {
|
|
const folder = removalFolder(get());
|
|
if (!folder) return false;
|
|
// Rows are matched by key, not id: ids of different accounts can collide.
|
|
const removable = new Map<string, Email>();
|
|
for (const email of targets) {
|
|
if (canRemoveFromFolder(email, folder)) removable.set(emailKeyFor(email), email);
|
|
}
|
|
if (removable.size === 0) return false;
|
|
|
|
const mailboxes = resolveActionMailboxes();
|
|
const leaving = [...removable.values()];
|
|
// Plugins see it as a move out of the folder that lands nowhere new.
|
|
const move = { emailIds: [...new Set(leaving.map(e => e.id))], fromMailboxId: folder.id, toMailboxId: null };
|
|
if (!(await allowEmailMove(move, mailboxes))) return false;
|
|
|
|
try {
|
|
await resolveActionClient(client).removeEmailsFromMailbox(
|
|
move.emailIds,
|
|
folder.originalId || folder.id,
|
|
folder.isShared ? folder.accountId : undefined,
|
|
);
|
|
} catch (error) {
|
|
set({ error: error instanceof Error ? error.message : 'Failed to remove from folder' });
|
|
throw error;
|
|
}
|
|
|
|
const isLeaving = (e: Email) => removable.has(emailKeyFor(e));
|
|
const unread = leaving.filter(e => !e.keywords?.$seen).length;
|
|
const withoutFolder = (e: Email): Email => {
|
|
const mailboxIds = { ...e.mailboxIds };
|
|
delete mailboxIds[folder.id];
|
|
if (folder.originalId) delete mailboxIds[folder.originalId];
|
|
return { ...e, mailboxIds };
|
|
};
|
|
|
|
set((state) => {
|
|
// Only the open folder loses them; their other folders keep them.
|
|
const adjust = (mb: Mailbox): Mailbox => mb.id !== folder.id ? mb : {
|
|
...mb,
|
|
totalEmails: Math.max(0, mb.totalEmails - leaving.length),
|
|
unreadEmails: Math.max(0, mb.unreadEmails - unread),
|
|
};
|
|
const inViewingList = !!state.viewingAccountId && !!state.accountMailboxes[state.viewingAccountId];
|
|
const counters = inViewingList
|
|
? { accountMailboxes: { ...state.accountMailboxes, [state.viewingAccountId!]: state.accountMailboxes[state.viewingAccountId!].map(adjust) } }
|
|
: { mailboxes: state.mailboxes.map(adjust) };
|
|
|
|
// The open message moves on to its neighbour, as after a move.
|
|
let selectedEmail = state.selectedEmail;
|
|
if (selectedEmail && isLeaving(selectedEmail)) {
|
|
const index = state.emails.findIndex(e => emailKeyFor(e) === emailKeyFor(selectedEmail!));
|
|
const after = state.emails.slice(index + 1).find(e => !isLeaving(e));
|
|
const before = state.emails.slice(0, Math.max(0, index)).reverse().find(e => !isLeaving(e));
|
|
selectedEmail = index === -1 ? null : (removable.size === 1 ? after ?? before ?? null : null);
|
|
}
|
|
|
|
// Loaded thread members keep their other folders.
|
|
let threadEmailsCache = state.threadEmailsCache;
|
|
for (const [key, members] of state.threadEmailsCache) {
|
|
if (!members.some(isLeaving)) continue;
|
|
if (threadEmailsCache === state.threadEmailsCache) threadEmailsCache = new Map(state.threadEmailsCache);
|
|
threadEmailsCache.set(key, members.map(e => (isLeaving(e) ? withoutFolder(e) : e)));
|
|
}
|
|
|
|
return {
|
|
emails: state.emails.filter(e => !isLeaving(e)),
|
|
selectedEmail,
|
|
selectedEmailKeys: dropFromSelection(state, isLeaving),
|
|
threadEmailsCache,
|
|
...counters,
|
|
};
|
|
});
|
|
emitEmailMove(move, mailboxes);
|
|
return true;
|
|
},
|
|
|
|
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId, options): Promise<boolean> => {
|
|
if (emailIdsBySource.size === 0) return true;
|
|
const keepOriginal = options?.keepOriginal === true;
|
|
// A copy leaves the messages where they are, so only a move asks plugins.
|
|
const hookMailboxes = [...resolveActionMailboxes(), ...(get().accountMailboxes[destAccountId] ?? [])];
|
|
const requestedIds = [...new Set([...emailIdsBySource.values()].flat())];
|
|
const move = keepOriginal ? null : {
|
|
emailIds: requestedIds,
|
|
fromMailboxId: moveSourceMailboxId(get().emails.filter(e => requestedIds.includes(e.id)), hookMailboxes),
|
|
toMailboxId: destMailboxId,
|
|
};
|
|
if (move && !(await allowEmailMove(move, hookMailboxes))) return false;
|
|
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) => {
|
|
// Delegated/shared folders: one client reaches both accounts, so a
|
|
// server-side Email/copy moves the message. A client can't stage a
|
|
// blob in a *delegated* account (blobNotFound), so the blob
|
|
// copy+import path below is only valid across separate login
|
|
// clients/servers.
|
|
if (sourceClient === destClient) {
|
|
await sourceClient.copyEmailAcrossAccounts(
|
|
emailId,
|
|
sourceJmapAccountId ?? sourceClient.getAccountId(),
|
|
destJmapAccountId ?? destClient.getAccountId(),
|
|
destMailboxId,
|
|
keepOriginal ? { keepOriginal } : undefined,
|
|
);
|
|
return emailId;
|
|
}
|
|
// Separate clients (cross-server multi-account): the email, its
|
|
// blob, and the destroy all live in the owner's JMAP account, not
|
|
// the source client's primary one.
|
|
const full = await sourceClient.getEmail(emailId, sourceJmapAccountId);
|
|
if (!full?.blobId) {
|
|
throw new Error('Source email has no raw blob to copy');
|
|
}
|
|
const blob = await sourceClient.fetchBlob(full.blobId, undefined, undefined, sourceJmapAccountId);
|
|
// Every keyword, the server's attachment ones included (see
|
|
// lib/jmap/server-keywords.ts).
|
|
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
|
|
// The original date, or the copy sorts as today's mail.
|
|
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId, full.receivedAt);
|
|
if (!keepOriginal) await sourceClient.deleteEmail(emailId, sourceJmapAccountId);
|
|
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. A copy
|
|
// leaves the view and the selection as they are.
|
|
const movedSet = new Set(movedIds);
|
|
if (keepOriginal) set({ isLoading: false });
|
|
else set((state) => ({
|
|
emails: state.emails.filter((e) => !movedSet.has(e.id)),
|
|
selectedEmail:
|
|
state.selectedEmail && movedSet.has(state.selectedEmail.id)
|
|
? null
|
|
: state.selectedEmail,
|
|
selectedEmailKeys: (() => {
|
|
const next = new Set(state.selectedEmailKeys);
|
|
for (const e of state.emails) if (movedSet.has(e.id)) next.delete(emailKeyFor(e));
|
|
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>(keepOriginal ? [destAccountId] : [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 (move && movedIds.length > 0) emitEmailMove({ ...move, emailIds: movedIds }, hookMailboxes);
|
|
|
|
if (failures.length > 0) {
|
|
const first = failures[0];
|
|
const verb = keepOriginal ? 'copy' : 'move';
|
|
throw new Error(
|
|
failures.length === 1
|
|
? `Failed to ${verb} email: ${first.error}`
|
|
: `Failed to ${verb} ${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;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
copyEmailsToAccount: async (emailIds, destAccountId, destMailboxId) => {
|
|
const state = get();
|
|
const auth = useAuthStore.getState();
|
|
const viewLogin = state.viewingAccountId ?? auth.activeAccountId;
|
|
const currentMailbox = resolveActionMailboxes().find((mb) => mb.id === state.selectedMailbox);
|
|
// One group per (login, JMAP account): crossAccountMoveEmails takes a
|
|
// single source JMAP account per call.
|
|
const groups = new Map<string, { login: string; jmapAccountId?: string; ids: string[] }>();
|
|
for (const entry of emailIds) {
|
|
const id = typeof entry === 'string' ? entry : entry.id;
|
|
const email = typeof entry !== 'string' ? entry : state.emails.find((e) => e.id === id)
|
|
?? (state.selectedEmail?.id === id ? state.selectedEmail : undefined);
|
|
// Aggregate views stamp each email with the login that reaches it and
|
|
// its owning JMAP account; otherwise it lives in the open folder.
|
|
const stamped = !!(email?.sourceClientAccountId && email.sourceAccountId);
|
|
const login = stamped ? email!.sourceClientAccountId! : viewLogin;
|
|
const jmapAccountId = stamped
|
|
? email!.sourceAccountId
|
|
: (currentMailbox?.isShared ? currentMailbox.accountId : undefined);
|
|
if (!login) continue;
|
|
const key = `${login}|${jmapAccountId ?? ''}`;
|
|
if (!groups.has(key)) groups.set(key, { login, jmapAccountId, ids: [] });
|
|
groups.get(key)!.ids.push(id);
|
|
}
|
|
|
|
// Copying a message into its own account is not a cross-account copy.
|
|
const destPrimary = auth.getClientForAccount(destAccountId)?.getAccountId();
|
|
for (const { login, jmapAccountId } of groups.values()) {
|
|
if (login === destAccountId && (!jmapAccountId || jmapAccountId === destPrimary)) {
|
|
const error = new Error('The messages are already in that account');
|
|
set({ error: error.message });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
for (const { login, jmapAccountId, ids } of groups.values()) {
|
|
await get().crossAccountMoveEmails(
|
|
new Map([[login, ids]]),
|
|
destAccountId,
|
|
destMailboxId,
|
|
undefined,
|
|
jmapAccountId,
|
|
{ keepOriginal: true },
|
|
);
|
|
}
|
|
},
|
|
|
|
moveThreadToMailbox: async (client, emailId, destinationMailboxId, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
try {
|
|
const state = get();
|
|
const email = state.emails.find(isTarget)
|
|
?? ((!!state.selectedEmail && isTarget(state.selectedEmail)) ? state.selectedEmail : null);
|
|
|
|
if (!email?.threadId) {
|
|
return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey);
|
|
}
|
|
|
|
// In unified view route to the email's own account (client + that
|
|
// account's mailbox list); otherwise the active/viewing context. (#281)
|
|
const { client: effectiveClient, mailboxes, accountId } = resolveEmailActionContext(email, client);
|
|
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) {
|
|
return await get().moveToMailbox(client, emailId, destinationMailboxId, emailKey);
|
|
}
|
|
|
|
const move = { emailIds: threadEmailIds, fromMailboxId: moveSourceMailboxId([email], mailboxes), toMailboxId: destinationMailboxId };
|
|
if (!(await allowEmailMove(move, mailboxes))) return false;
|
|
|
|
await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
|
|
|
|
const removedEmailIds = new Set(threadEmailIds);
|
|
set((currentState) => {
|
|
const nextSelectedEmail = getNextSelectedEmailAfterRemoval(currentState, removedEmailIds);
|
|
const removedKeys = new Set(
|
|
currentState.emails.filter(e => removedEmailIds.has(e.id)).map(emailKeyFor)
|
|
);
|
|
const nextSelectedEmailIds = new Set(
|
|
Array.from(currentState.selectedEmailKeys).filter(key => !removedKeys.has(key))
|
|
);
|
|
const nextExpandedThreadIds = new Set(currentState.expandedThreadIds);
|
|
nextExpandedThreadIds.delete(threadKeyFor(email));
|
|
const nextThreadEmailsCache = new Map(currentState.threadEmailsCache);
|
|
nextThreadEmailsCache.delete(threadKeyFor(email));
|
|
|
|
return {
|
|
emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)),
|
|
selectedEmail: nextSelectedEmail,
|
|
selectedEmailKeys: nextSelectedEmailIds,
|
|
expandedThreadIds: nextExpandedThreadIds,
|
|
threadEmailsCache: nextThreadEmailsCache,
|
|
};
|
|
});
|
|
emitEmailMove(move, mailboxes);
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to move email thread"
|
|
});
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
searchEmails: async (client, typedQuery) => {
|
|
const { searchAbortController } = get();
|
|
|
|
if (searchAbortController) {
|
|
searchAbortController.abort();
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
// The rows on screen stay, dimmed, until the results land, as on a folder
|
|
// switch, instead of being torn down for a skeleton and rebuilt a round
|
|
// trip later. What the mail view renders from is written only once the
|
|
// request is on its way (see afterRequestDispatch); the controller is
|
|
// recorded at once so a superseding search can still cancel this one.
|
|
set({ searchAbortController: controller });
|
|
afterRequestDispatch(() => {
|
|
if (get().searchAbortController === controller) set({ isLoading: true, error: null, searchQuery: typedQuery });
|
|
});
|
|
try {
|
|
// The store keeps what the user typed; plugins' changes apply to this run.
|
|
// Without listeners the request goes out in this same tick, as before.
|
|
const search = emailHooks.onSearch.size > 0
|
|
? await transformSearch(typedQuery, get().searchFilters)
|
|
: { query: typedQuery, filters: get().searchFilters };
|
|
if (controller.signal.aborted) return;
|
|
if (!isFilterEmpty(search.filters)) {
|
|
// A plugin added filters: only the advanced search applies them.
|
|
await get().advancedSearch(client, search);
|
|
return;
|
|
}
|
|
const query = search.query;
|
|
const { isUnifiedView, unifiedRole, crossView, searchMailboxId, searchFilters, selectedKeyword } = get();
|
|
startProvidedSearchResults(query, searchFilters, { get, set });
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
|
|
let result;
|
|
let accountId;
|
|
let unifiedErrors;
|
|
|
|
if (isUnifiedView && crossView) {
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0);
|
|
unifiedErrors = result.errors;
|
|
|
|
} else if (isUnifiedView && unifiedRole) {
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
|
|
unifiedErrors = result.errors;
|
|
|
|
} else if (selectedKeyword) {
|
|
// A tag view takes precedence over the folder, as it does in
|
|
// fetchEmails and refreshCurrentMailbox — so searching inside one
|
|
// NARROWS the tag rather than silently replacing the list with an
|
|
// unrelated all-folders result that the next push refresh would throw
|
|
// away again.
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await fetchTagEmails(
|
|
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
|
|
textFilter(query),
|
|
);
|
|
unifiedErrors = result.errors;
|
|
|
|
} else if (isAllFoldersSearchScope(searchMailboxId)) {
|
|
// Folder-less scope: the folders of the own account AND of every
|
|
// group/shared account this login reaches - the same fan-out the tag
|
|
// views use (#1038). A single own-account query silently skipped the
|
|
// shared accounts' mail (#1082). The default scope leaves every
|
|
// account's Trash and Junk out; "All folders" searches them too.
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await searchAcrossAccounts(built, query, emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true });
|
|
unifiedErrors = result.errors;
|
|
|
|
} else {
|
|
// Scope the search to the folder picked in the search panel.
|
|
const mailboxes = resolveActionMailboxes();
|
|
const mailbox = mailboxes.find(mb => mb.id === searchMailboxId);
|
|
// Use originalId for shared mailboxes
|
|
const jmapMailboxId = mailbox?.originalId || searchMailboxId;
|
|
// Only pass accountId for shared mailboxes, not for primary account
|
|
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
|
|
result = withSnippetBatches(await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0, { deferSnippets: true }));
|
|
}
|
|
|
|
// Plugins may add messages to the results by id; the messages are
|
|
// fetched from the server, so a plugin can't put made-up rows in the list.
|
|
const extraIds = await pluginSearchResultIds(query, searchFilters, result.emails);
|
|
if (extraIds.length > 0) {
|
|
const newEmails = await resolveActionClient(client).getSomeEmails(extraIds, accountId);
|
|
result.emails.push(...newEmails);
|
|
result.total += newEmails.length;
|
|
}
|
|
|
|
if (controller.signal.aborted) return;
|
|
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
set({
|
|
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
// Also here: a fast answer can land before the deferred loading write.
|
|
searchQuery: typedQuery,
|
|
isLoading: false,
|
|
searchAbortController: null,
|
|
...(unifiedErrors ? { unifiedErrors } : {})
|
|
});
|
|
highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined);
|
|
} catch (error) {
|
|
if (controller.signal.aborted) return;
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to search emails",
|
|
searchQuery: typedQuery,
|
|
isLoading: false,
|
|
emails: [],
|
|
externalSearchResults: [],
|
|
hasMoreEmails: false,
|
|
totalEmails: 0,
|
|
searchAbortController: null,
|
|
});
|
|
}
|
|
},
|
|
|
|
advancedSearch: async (client, transformed) => {
|
|
const { searchQuery: typedQuery, searchFilters: typedFilters, searchMailboxId, searchAbortController, isUnifiedView, unifiedRole, crossView, selectedKeyword } = get();
|
|
const mailboxes = resolveActionMailboxes();
|
|
|
|
if (searchAbortController) {
|
|
searchAbortController.abort();
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
// See searchEmails: the rows stay, the loading state follows the request.
|
|
set({ searchAbortController: controller });
|
|
afterRequestDispatch(() => {
|
|
if (get().searchAbortController === controller) set({ isLoading: true, error: null });
|
|
});
|
|
|
|
try {
|
|
const { query: searchQuery, filters: searchFilters } = transformed
|
|
?? (emailHooks.onSearch.size > 0 ? await transformSearch(typedQuery, typedFilters) : { query: typedQuery, filters: typedFilters });
|
|
if (controller.signal.aborted) return;
|
|
startProvidedSearchResults(searchQuery, searchFilters, { get, set });
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
let result;
|
|
let accountId;
|
|
let unifiedErrors;
|
|
|
|
if (isUnifiedView && crossView) {
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
// Cross views apply the advanced filter (text + fields) on top of the
|
|
// view membership; an empty filter degrades to a plain membership query.
|
|
result = await advancedSearchCrossViewEmails(
|
|
built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
|
|
);
|
|
unifiedErrors = result.errors;
|
|
|
|
} else if (isUnifiedView && unifiedRole) {
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
result = await advancedSearchUnifiedEmails(
|
|
built,
|
|
unifiedRole,
|
|
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
|
|
emailsPerPage,
|
|
0,
|
|
);
|
|
unifiedErrors = result.errors;
|
|
|
|
} else if (selectedKeyword) {
|
|
// Narrow the tag rather than replace it — see searchEmails.
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await fetchTagEmails(
|
|
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
|
|
buildJMAPFilter(searchQuery, searchFilters, undefined),
|
|
);
|
|
unifiedErrors = result.errors;
|
|
|
|
} else if (isAllFoldersSearchScope(searchMailboxId)) {
|
|
// Folder-less scope: fan out over the own + group/shared accounts with
|
|
// a filter that carries no inMailbox clause (#1082).
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await advancedSearchAcrossAccounts(
|
|
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
|
|
{ ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true },
|
|
);
|
|
unifiedErrors = result.errors;
|
|
|
|
} else {
|
|
const mailbox = mailboxes.find(mb => mb.id === searchMailboxId);
|
|
const jmapMailboxId = mailbox?.originalId || searchMailboxId;
|
|
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
|
|
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
|
result = withSnippetBatches(await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0, { deferSnippets: true }));
|
|
}
|
|
|
|
if (controller.signal.aborted) return;
|
|
|
|
// Plugins may add messages to the results by id; the messages are
|
|
// fetched from the server, so a plugin can't put made-up rows in the list.
|
|
const extraIds = await pluginSearchResultIds(searchQuery, searchFilters, result.emails);
|
|
if (extraIds.length > 0) {
|
|
const newEmails = await resolveActionClient(client).getSomeEmails(extraIds, accountId);
|
|
result.emails.push(...newEmails);
|
|
result.total += newEmails.length;
|
|
}
|
|
|
|
if (controller.signal.aborted) return;
|
|
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
set({
|
|
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
isLoading: false,
|
|
searchAbortController: null,
|
|
...(unifiedErrors ? { unifiedErrors } : {})
|
|
});
|
|
highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined);
|
|
} 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 },
|
|
}));
|
|
},
|
|
|
|
setSearchMailboxId: (mailboxId) => {
|
|
set({ searchMailboxId: mailboxId, searchScopeFollowsFolder: false });
|
|
},
|
|
|
|
// The unread badge filters its own folder. The default scope searches
|
|
// every folder of every account (#788, #1082), so it has to name the
|
|
// folder, and keep naming whichever folder is open.
|
|
scopeSearchToOpenFolder: () => {
|
|
set((state) => ({ searchMailboxId: state.selectedMailbox, searchScopeFollowsFolder: true }));
|
|
},
|
|
|
|
clearSearchFilters: () => {
|
|
set((state) => ({
|
|
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
|
searchMailboxId: defaultSearchScopeFor(mailboxesInView(state), state.selectedMailbox),
|
|
searchScopeFollowsFolder: false,
|
|
}));
|
|
},
|
|
|
|
toggleAdvancedSearch: () => {
|
|
set((state) => ({ isAdvancedSearchOpen: !state.isAdvancedSearchOpen }));
|
|
},
|
|
|
|
toggleStar: async (client, emailId, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
try {
|
|
const email = get().emails.find(isTarget);
|
|
if (!email) return false;
|
|
|
|
const isFlagged = email.keywords.$flagged || false;
|
|
if (emailHooks.onBeforeEmailStarToggle.size > 0
|
|
&& !(await emailHooks.onBeforeEmailStarToggle.intercept({ emailIds: [emailId], starred: !isFlagged }))) {
|
|
return false;
|
|
}
|
|
// In unified view route to the email's own account client + owner accountId
|
|
// (the reaching client's primary is not the owner for shared sources). (#281)
|
|
const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
|
|
await actionClient.toggleStar(emailId, !isFlagged, accountId);
|
|
|
|
// Update local state
|
|
set((state) => ({
|
|
emails: state.emails.map(e =>
|
|
isTarget(e) ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e
|
|
),
|
|
selectedEmail: (!!state.selectedEmail && isTarget(state.selectedEmail))
|
|
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } }
|
|
: state.selectedEmail,
|
|
// In the starred cross view, un-starring drops the mail out of the
|
|
// hasKeyword:$flagged filter. Retain the row so it stays visible until the
|
|
// view is re-opened; re-starring makes it match again, so drop retention.
|
|
retainedInViewIds: updateRetainedForView(
|
|
state.retainedInViewIds,
|
|
retainingCrossView(state) === 'starred' ? (isFlagged ? 'add' : 'remove') : 'none',
|
|
emailId,
|
|
),
|
|
}));
|
|
void emailHooks.onEmailStarToggle.emit({ emailIds: [emailId], starred: !isFlagged });
|
|
refillAfterKeywordChange(get, client, ['$flagged']);
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to update star"
|
|
});
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
markEmailKeyword: async (client, emailId, keyword, source) => {
|
|
// Single-flag counterpart of setEmailKeywords ($answered / $forwarded /
|
|
// $mdnsent). Same routing, same reason: an unrouted write against the
|
|
// reaching client's primary account is a silent no-op on a shared/group
|
|
// message. Best-effort by contract - callers treat a failure as non-fatal.
|
|
if (emailHooks.onBeforeEmailKeywordChange.size > 0
|
|
&& !(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds: [emailId], added: [keyword], removed: [] }))) {
|
|
return;
|
|
}
|
|
const { client: actionClient, accountId } = source?.clientAccountId
|
|
? { client: stampedClient(source.clientAccountId), accountId: source.accountId }
|
|
: resolveKeywordActionContext(emailId, client);
|
|
await actionClient.setKeyword(emailId, keyword, accountId);
|
|
void emailHooks.onEmailKeywordChange.emit({ emailIds: [emailId], added: [keyword], removed: [] });
|
|
},
|
|
|
|
setEmailKeywords: async (client, emailId, keywords, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
// Route the write to the email's OWN account, exactly like toggleStar and
|
|
// every other single-email action. `resolveEmailActionContext` covers both
|
|
// shapes: in an aggregate view the owner comes from the email's
|
|
// `sourceAccountId`, and when a shared/group folder is selected directly in
|
|
// the "Shared" sidebar section - where emails are undecorated - it falls back
|
|
// to the owner of the selected mailbox.
|
|
//
|
|
// Resolving only the aggregate shape (`isUnifiedView ? ... : undefined`) sent
|
|
// the write to the reaching client's primary account, which the server
|
|
// answers with `notUpdated`/`updated: null` and no error, so the keyword was
|
|
// silently lost on the next reload - the same class of bug as the batch
|
|
// actions in `email-store-shared-folder-actions.test.ts`. (#281)
|
|
//
|
|
// Only the keywords that differ from the row are written (see
|
|
// patchEmailKeywords): replacing the whole set from the row undid what
|
|
// another client had set since, such as `$seen` or `$answered`.
|
|
const previous = (get().emails.find(isTarget) ?? get().selectedEmail)?.keywords ?? {};
|
|
const changes: Record<string, boolean> = {};
|
|
for (const key of new Set([...Object.keys(previous), ...Object.keys(keywords)])) {
|
|
if (!!previous[key] !== !!keywords[key]) changes[key] = !!keywords[key];
|
|
}
|
|
return await get().patchEmailKeywords(client, emailId, changes, emailKey);
|
|
},
|
|
|
|
patchEmailKeywords: async (client, emailId, changes, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
const keys = Object.keys(changes);
|
|
if (keys.length === 0) return true;
|
|
if (emailHooks.onBeforeEmailKeywordChange.size > 0
|
|
&& !(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds: [emailId], ...keywordChangeLists(changes) }))) {
|
|
return false;
|
|
}
|
|
const { client: actionClient, accountId } = resolveKeywordActionContext(emailId, client, emailKey);
|
|
// `keywords/<name>` pointers touch only these keywords on the server;
|
|
// a cleared keyword is removed (null), since a keyword map holds `true`
|
|
// values only.
|
|
const patch = Object.fromEntries(keys.map(key => [keywordPointer(key), changes[key] ? true : null]));
|
|
await actionClient.batchUpdateKeywords([emailId], patch, accountId);
|
|
|
|
const previous = (get().emails.find(isTarget) ?? get().selectedEmail)?.keywords ?? {};
|
|
const next: Record<string, boolean> = { ...previous };
|
|
for (const key of keys) {
|
|
if (changes[key]) next[key] = true;
|
|
else delete next[key];
|
|
}
|
|
get().setEmailKeywordsLocal(emailId, next, emailKey);
|
|
void emailHooks.onEmailKeywordChange.emit({ emailIds: [emailId], ...keywordChangeLists(changes) });
|
|
refillAfterKeywordChange(get, client, keys.filter(k => !!previous[k] !== !!changes[k]));
|
|
return true;
|
|
},
|
|
|
|
setEmailKeywordsLocal: (emailId, keywords, emailKey) => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
set((state) => {
|
|
// This patch replaces the whole keyword map, so it can flip $seen as well
|
|
// as labels. Only a genuine read-state change moves the tag unread counts.
|
|
const previous = state.emails.find(isTarget) ?? state.selectedEmail;
|
|
const wasRead = previous?.keywords?.$seen ?? false;
|
|
const isRead = keywords.$seen ?? false;
|
|
const delta = wasRead === isRead ? 0 : (isRead ? -1 : 1);
|
|
|
|
return {
|
|
emails: state.emails.map(e =>
|
|
isTarget(e) ? { ...e, keywords: { ...keywords } } : e
|
|
),
|
|
selectedEmail: (!!state.selectedEmail && isTarget(state.selectedEmail))
|
|
? { ...state.selectedEmail, keywords: { ...keywords } }
|
|
: state.selectedEmail,
|
|
tagCounts: applyTagCountReadDelta(state.tagCounts, [{ keywords, delta }]),
|
|
};
|
|
});
|
|
},
|
|
|
|
// Batch operations
|
|
batchMarkAsRead: async (client, read, target): Promise<boolean> => {
|
|
const { emails } = get();
|
|
// The selection is keyed by owning account; JMAP takes the bare ids of
|
|
// exactly those emails, never the keys and never a lookup by id (which
|
|
// is what let a namesake in another account ride along). A targeted call
|
|
// names its messages, and the account they belong to, itself. In a list
|
|
// that mixes accounts a selected row found nowhere is left alone and
|
|
// reported (resolveSelectionRows).
|
|
const selection = target ? null : resolveSelectionRows();
|
|
const selectedEmails = selection?.rows ?? [];
|
|
const notFound = selection && isAggregateListView() ? selection.unknown : [];
|
|
const targetIds = target ? new Set(target.emailIds) : null;
|
|
const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id);
|
|
if (emailIdsArray.length === 0 && notFound.length === 0) return true;
|
|
if (emailIdsArray.length > 0 && emailHooks.onBeforeEmailReadStateChange.size > 0
|
|
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [...new Set(emailIdsArray)], read }))) {
|
|
return false;
|
|
}
|
|
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
// Each write names the rows it covers: by key for the selection, so a
|
|
// refusal in one account leaves a namesake in another marked; by id for
|
|
// a targeted call, whose messages need not be loaded.
|
|
const writes: { ids: string[]; rows: Email[]; write: () => Promise<void> }[] = [];
|
|
if (target) {
|
|
// Messages named by the caller need not be loaded, so the list's
|
|
// source stamps cannot route them; the caller names the account.
|
|
writes.push({
|
|
ids: emailIdsArray,
|
|
rows: [],
|
|
write: () => target.client.batchMarkAsRead(emailIdsArray, read, target.accountId),
|
|
});
|
|
} else if (isAggregateListView()) {
|
|
// Group by login and owning JMAP account; dispatch through the reaching
|
|
// login client (groupBySource).
|
|
for (const batch of groupBySource(selectedEmails)) {
|
|
const acctClient = sourceBatchClient(batch, client);
|
|
writes.push({
|
|
ids: batch.ids,
|
|
rows: batch.messages,
|
|
write: acctClient
|
|
? () => acctClient.batchMarkAsRead(batch.ids, read, batch.accountId)
|
|
: () => Promise.reject(new Error('Not connected')),
|
|
});
|
|
}
|
|
if (notFound.length > 0) {
|
|
writes.push({ ids: [], rows: [], write: () => Promise.reject(new Error(NOT_IN_LIST)) });
|
|
}
|
|
} else {
|
|
// Non-unified: route to the viewed shared/group account (if any), reached
|
|
// through the active client; undefined for a normal own-account view.
|
|
writes.push({
|
|
ids: emailIdsArray,
|
|
rows: selectedEmails,
|
|
write: () => resolveActionClient(client).batchMarkAsRead(emailIdsArray, read, resolveViewAccountId()),
|
|
});
|
|
}
|
|
|
|
// A message the server refused (a read-only shared folder) keeps its
|
|
// state; the rest of the selection is written and shown as such, and
|
|
// the refusal is reported through `error`.
|
|
const outcomes = await Promise.allSettled(writes.map(({ write }) => write()));
|
|
const changedIds = new Set<string>();
|
|
const changedKeys = new Set<string>();
|
|
let failure: unknown = null;
|
|
for (let i = 0; i < outcomes.length; i++) {
|
|
const outcome = outcomes[i];
|
|
let refused: Set<string> | null = null;
|
|
if (outcome.status === 'rejected') {
|
|
failure ??= outcome.reason;
|
|
refused = new Set(jmapErrorFailedIds(outcome.reason) ?? writes[i].ids);
|
|
}
|
|
for (const id of writes[i].ids) if (!refused?.has(id)) changedIds.add(id);
|
|
for (const row of writes[i].rows) if (!refused?.has(row.id)) changedKeys.add(emailKeyFor(row));
|
|
}
|
|
const changed = (email: Email) => (targetIds ? changedIds.has(email.id) : changedKeys.has(emailKeyFor(email)));
|
|
if (failure && (targetIds ? changedIds.size : changedKeys.size) === 0) throw failure;
|
|
|
|
// Update local state
|
|
const updatedEmails = emails.map(email =>
|
|
changed(email)
|
|
? { ...email, keywords: { ...email.keywords, $seen: read } }
|
|
: email
|
|
);
|
|
|
|
// Update mailbox counters per the email's own account list (#281). The
|
|
// selection's rows include the members of expanded threads.
|
|
const affectedEmails = (targetIds ? emails : selectedEmails).filter(changed);
|
|
const mailboxPatch = applyBatchMailboxCounterUpdate(get(), affectedEmails, (mailbox, group) => {
|
|
let deltaUnread = 0;
|
|
for (const email of group as Email[]) {
|
|
if (emailInMailbox(email, mailbox) && (email.keywords?.$seen ?? false) !== read) {
|
|
deltaUnread += read ? -1 : 1;
|
|
}
|
|
}
|
|
return deltaUnread === 0 ? mailbox : {
|
|
...mailbox,
|
|
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread),
|
|
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread),
|
|
};
|
|
});
|
|
|
|
// Tag badges follow the same delta as the folder counters, counting only
|
|
// the emails whose read state actually changed.
|
|
const tagCounts = applyTagCountReadDelta(
|
|
get().tagCounts,
|
|
affectedEmails
|
|
.filter(email => (email.keywords?.$seen ?? false) !== read)
|
|
.map(email => ({ keywords: email.keywords, delta: read ? -1 : 1 })),
|
|
);
|
|
|
|
// In the unread cross view, retain every mail we just marked read so the
|
|
// rows stay put (status + counter update only); marking unread drops them.
|
|
let retainedInViewIds = get().retainedInViewIds;
|
|
if (retainingCrossView(get()) === 'unread') {
|
|
const op = read ? 'add' : 'remove';
|
|
for (const e of affectedEmails) {
|
|
retainedInViewIds = updateRetainedForView(retainedInViewIds, op, e.id);
|
|
}
|
|
}
|
|
|
|
// The members of expanded threads show their new state too.
|
|
const affectedKeys = new Set(affectedEmails.map(sourceRowKey));
|
|
const threadEmailsCache = new Map(get().threadEmailsCache);
|
|
for (const [threadKey, members] of threadEmailsCache) {
|
|
if (!members.some(member => affectedKeys.has(sourceRowKey(member)))) continue;
|
|
threadEmailsCache.set(threadKey, members.map(member => affectedKeys.has(sourceRowKey(member))
|
|
? { ...member, keywords: { ...member.keywords, $seen: read } }
|
|
: member));
|
|
}
|
|
|
|
set({
|
|
emails: updatedEmails,
|
|
...mailboxPatch,
|
|
tagCounts,
|
|
retainedInViewIds,
|
|
threadEmailsCache,
|
|
// The user's own selection is not what a targeted call acted on.
|
|
selectedEmailKeys: target ? get().selectedEmailKeys : new Set(notFound),
|
|
isLoading: false,
|
|
error: failure ? (failure instanceof Error ? failure.message : "Failed to update emails") : null,
|
|
});
|
|
if (changedIds.size > 0) void emailHooks.onEmailReadStateChange.emit({ emailIds: [...changedIds], read });
|
|
refillAfterKeywordChange(get, client, ['$seen']);
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to update emails",
|
|
isLoading: false
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
batchSetTag: async (client, tagId, add): Promise<boolean> => {
|
|
const { selectedEmailKeys } = get();
|
|
if (selectedEmailKeys.size === 0) return true;
|
|
|
|
// Both prefixes name the same tag when read, so taking one off clears
|
|
// either spelling; putting one on writes the current spelling only.
|
|
const tagKeys = [KEYWORD_PREFIX + tagId, KEYWORD_PREFIX_LEGACY + tagId];
|
|
const changedKeys = add ? [tagKeys[0]] : tagKeys;
|
|
const patch = Object.fromEntries(changedKeys.map(key => [keywordPointer(key), add ? true : null]));
|
|
|
|
// Group by the account each message lives in, exactly as a single-message
|
|
// tag change is routed: its source in an aggregate view, the selected
|
|
// folder's owner in a shared folder. (#281) The selection is keyed by
|
|
// owning account, so a namesake in another account is never written.
|
|
const groups = new Map<IJMAPClient, Map<string | undefined, Email[]>>();
|
|
for (const email of resolveSelectedEmails(get())) {
|
|
// A message already in the wanted state needs no write.
|
|
if (tagKeys.some(key => email.keywords?.[key] === true) === add) continue;
|
|
const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
|
|
const byAccount = groups.get(actionClient) ?? new Map<string | undefined, Email[]>();
|
|
groups.set(actionClient, byAccount);
|
|
byAccount.set(accountId, [...(byAccount.get(accountId) ?? []), email]);
|
|
}
|
|
|
|
if (groups.size === 0) return true;
|
|
const tagChange = add ? { added: changedKeys, removed: [] } : { added: [], removed: changedKeys };
|
|
if (emailHooks.onBeforeEmailKeywordChange.size > 0) {
|
|
const emailIds = [...new Set([...groups.values()].flatMap(byAccount => [...byAccount.values()].flat().map(e => e.id)))];
|
|
if (!(await emailHooks.onBeforeEmailKeywordChange.intercept({ emailIds, ...tagChange }))) return false;
|
|
}
|
|
|
|
const writes = [...groups].flatMap(([actionClient, byAccount]) =>
|
|
[...byAccount].map(async ([accountId, group]) => {
|
|
await actionClient.batchUpdateKeywords(group.map(e => e.id), patch, accountId);
|
|
return group;
|
|
}),
|
|
);
|
|
const settled = await Promise.allSettled(writes);
|
|
const writtenEmails = settled.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
|
const results = settled.map(result => result.status === 'fulfilled'
|
|
? { status: 'fulfilled' as const, value: result.value.map(emailKeyFor) }
|
|
: result);
|
|
|
|
// Only the accounts whose write went through are patched locally.
|
|
const written = new Set(results.flatMap(result => result.status === 'fulfilled' ? result.value : []));
|
|
const retag = (keywords: Record<string, boolean> | undefined) => {
|
|
const next = { ...keywords };
|
|
if (add) next[tagKeys[0]] = true;
|
|
else for (const key of tagKeys) delete next[key];
|
|
return next;
|
|
};
|
|
set((state) => ({
|
|
emails: state.emails.map(e => written.has(emailKeyFor(e)) ? { ...e, keywords: retag(e.keywords) } : e),
|
|
selectedEmail: state.selectedEmail && written.has(emailKeyFor(state.selectedEmail))
|
|
? { ...state.selectedEmail, keywords: retag(state.selectedEmail.keywords) }
|
|
: state.selectedEmail,
|
|
}));
|
|
if (written.size > 0) {
|
|
void emailHooks.onEmailKeywordChange.emit({ emailIds: [...new Set(writtenEmails.map(e => e.id))], ...tagChange });
|
|
void get().fetchTagCounts(client);
|
|
refillAfterKeywordChange(get, client, changedKeys);
|
|
}
|
|
|
|
const failed = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
|
|
if (failed) throw failed.reason;
|
|
return true;
|
|
},
|
|
|
|
batchDelete: async (client, permanent = false): Promise<boolean> => {
|
|
const { selectedEmailKeys, emails, selectedMailbox } = get();
|
|
const mailboxes = resolveActionMailboxes();
|
|
if (selectedEmailKeys.size === 0) return true;
|
|
|
|
if (emailHooks.onBeforeEmailDelete.size > 0) {
|
|
const toDelete = resolveSelectedEmails(get());
|
|
if (toDelete.length > 0) {
|
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
|
const destroys = permanent || bulkDeleteIsPermanent(currentMailbox);
|
|
if (!(await emailHooks.onBeforeEmailDelete.intercept({ emailIds: [...new Set(toDelete.map(e => e.id))], permanent: destroys }))) return false;
|
|
}
|
|
}
|
|
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
// The selection is keyed by owning account; JMAP takes the bare ids of
|
|
// exactly those emails, never the keys and never a lookup by id (which
|
|
// is what let a namesake in another account ride along). In a list that
|
|
// mixes accounts a selected row found nowhere is left alone and
|
|
// reported (resolveSelectionRows).
|
|
const { rows: selectedEmails, unknown } = resolveSelectionRows();
|
|
const notFound = isAggregateListView() ? unknown : [];
|
|
if (selectedEmails.length === 0) {
|
|
if (notFound.length > 0) {
|
|
set({ error: NOT_IN_LIST, isLoading: false });
|
|
return false;
|
|
}
|
|
set({ selectedEmailKeys: new Set(), isLoading: false });
|
|
return true;
|
|
}
|
|
|
|
// 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 login and owning JMAP account (handles unified view and
|
|
// search results spanning accounts). Each group resolves the reaching login
|
|
// client via `sourceClientAccountId` and routes JMAP via `sourceAccountId`.
|
|
// Undecorated emails (normal single-mailbox view) form one group for the
|
|
// active client (groupBySource).
|
|
const batches = groupBySource(selectedEmails);
|
|
|
|
// Undecorated emails viewed in a shared/group folder directly (non-unified)
|
|
// are reached through the active client but must carry the owner accountId,
|
|
// or the move/destroy silently no-ops on the user's own account (see
|
|
// resolveViewAccountId). undefined for a normal own-account view.
|
|
const viewAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
|
// Each group's trash comes from its own login's folder list: another
|
|
// login may use the same account id for an account with other folders,
|
|
// which is what the list cached under the bare account id can hold.
|
|
const mailboxesFor = (batch: SourceBatch) => (!batch.accountId ? mailboxes : sourceBatchMailboxes(batch));
|
|
const jmapIdFor = (batch: SourceBatch) => batch.accountId ?? viewAccountId;
|
|
|
|
// Ids the server confirmed, for onAfterEmailDelete.
|
|
const deletedIds = new Set<string>();
|
|
if (forceDestroy) {
|
|
const promises = batches.map(async (batch) => {
|
|
const acctClient = sourceBatchClient(batch, client);
|
|
if (!acctClient) return;
|
|
await acctClient.batchDeleteEmails(batch.ids, jmapIdFor(batch));
|
|
batch.ids.forEach(id => deletedIds.add(id));
|
|
});
|
|
await Promise.allSettled(promises);
|
|
} else {
|
|
// Move to trash per account.
|
|
let failedAccounts = 0;
|
|
// Keys, not ids: a namesake in an account that failed must stay.
|
|
const movedKeys = new Set<string>();
|
|
const promises = batches.map(async (batch) => {
|
|
const acctClient = sourceBatchClient(batch, client);
|
|
if (!acctClient) {
|
|
failedAccounts++;
|
|
return;
|
|
}
|
|
const trashMailbox = findTrashMailbox(mailboxesFor(batch), {
|
|
accountId: jmapIdFor(batch),
|
|
});
|
|
if (!trashMailbox) {
|
|
// No trash for this account: skip rather than silently destroying.
|
|
// The user asked to move to trash, not permanently delete.
|
|
failedAccounts++;
|
|
return;
|
|
}
|
|
const trashId = trashMailbox.originalId || trashMailbox.id;
|
|
await acctClient.batchMoveEmails(batch.ids, trashId, jmapIdFor(batch), alsoMarkRead);
|
|
batch.messages.forEach(email => movedKeys.add(emailKeyFor(email)));
|
|
batch.ids.forEach(id => deletedIds.add(id));
|
|
});
|
|
await Promise.allSettled(promises);
|
|
|
|
if (failedAccounts > 0 && movedKeys.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 (movedKeys.size < selectedEmails.length) {
|
|
const deletedEmails = selectedEmails.filter(e => movedKeys.has(emailKeyFor(e)));
|
|
const remainingEmails = emails.filter(e => !movedKeys.has(emailKeyFor(e)));
|
|
const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters);
|
|
set({
|
|
emails: remainingEmails,
|
|
...mailboxPatch,
|
|
selectedEmailKeys: new Set(notFound),
|
|
selectedEmail: null,
|
|
isLoading: false,
|
|
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
|
|
});
|
|
void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: false });
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Remove deleted emails from local state
|
|
const remainingEmails = emails.filter(e => !selectedEmailKeys.has(emailKeyFor(e)));
|
|
|
|
// Update mailbox counters per the email's own account list (#281). The
|
|
// selection's rows include the members of expanded threads.
|
|
const mailboxPatch = applyBatchMailboxCounterUpdate(get(), selectedEmails, applyDeleteCounters);
|
|
|
|
set({
|
|
emails: remainingEmails,
|
|
...mailboxPatch,
|
|
selectedEmailKeys: new Set(notFound),
|
|
selectedEmail: null,
|
|
isLoading: false,
|
|
...(notFound.length > 0 ? { error: NOT_IN_LIST } : {}),
|
|
});
|
|
if (deletedIds.size > 0) void emailHooks.onAfterEmailDelete.emit({ emailIds: [...deletedIds], permanent: forceDestroy });
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to delete emails",
|
|
isLoading: false
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
batchMoveToMailbox: async (client, toMailboxId, target): Promise<boolean> => {
|
|
const { selectedEmailKeys, emails } = get();
|
|
// Keyed selection as in batchMarkAsRead; a targeted call names its own.
|
|
const selection = target ? null : resolveSelectionRows();
|
|
const selectedEmails = selection?.rows ?? [];
|
|
const targetIds = target ? new Set(target.emailIds) : null;
|
|
const emailIdsArray = targetIds ? Array.from(targetIds) : selectedEmails.map(e => e.id);
|
|
if (emailIdsArray.length === 0 && !(selection && isAggregateListView() && selection.unknown.length > 0)) return true;
|
|
const hookMailboxes = resolveActionMailboxes();
|
|
const move = {
|
|
emailIds: [...new Set(emailIdsArray)],
|
|
fromMailboxId: moveSourceMailboxId(targetIds ? emails.filter(e => targetIds.has(e.id)) : selectedEmails, hookMailboxes),
|
|
toMailboxId,
|
|
};
|
|
if (move.emailIds.length > 0 && !(await allowEmailMove(move, hookMailboxes))) return false;
|
|
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
// Ids of an account group whose move failed.
|
|
const notMoved = new Set<string>();
|
|
|
|
if (!target && isAggregateListView()) {
|
|
// Only messages in the folder's own login and account change folders
|
|
// in place; the others are moved across accounts (moveSourceBatches).
|
|
// What could not be moved stays listed and selected, and so does a
|
|
// selected row found neither listed nor in an expanded thread
|
|
// (resolveSelectionRows).
|
|
const failures = await moveRowsToMailbox(selectedEmails, resolveMoveDestination(client, toMailboxId), client);
|
|
for (const failure of failures) failure.ids.forEach(id => notMoved.add(id));
|
|
failures.push(...notFoundFailures(selection?.unknown ?? []));
|
|
set({ isLoading: false, error: failures.length > 0 ? batchFailureMessage(failures, 'move') : null });
|
|
} else {
|
|
if (target) {
|
|
// Messages named by the caller need not be loaded, so the list's
|
|
// source stamps cannot route them; the caller names the account and
|
|
// the destination's JMAP id.
|
|
await target.client.batchMoveEmails(emailIdsArray, toMailboxId, target.accountId);
|
|
} else {
|
|
// Non-unified: route to the viewed shared/group account (if any), reached
|
|
// through the active client. Resolve the destination to its bare owner id
|
|
// (`originalId`) since shared folders use namespaced store ids.
|
|
const viewAccountId = resolveViewAccountId();
|
|
const destMailbox = resolveActionMailboxes().find(mb => mb.id === toMailboxId);
|
|
const jmapDestId = destMailbox?.originalId || toMailboxId;
|
|
await resolveActionClient(client).batchMoveEmails(emailIdsArray, jmapDestId, viewAccountId);
|
|
}
|
|
|
|
// Update local state - remove from current view since they moved
|
|
const moved = (e: Email) => (targetIds ? targetIds.has(e.id) : selectedEmailKeys.has(emailKeyFor(e)));
|
|
const remainingEmails = emails.filter(e => !moved(e));
|
|
|
|
set({
|
|
emails: remainingEmails,
|
|
// A targeted call only drops the moved messages from the selection.
|
|
selectedEmailKeys: target ? dropFromSelection(get(), moved) : new Set(),
|
|
isLoading: false
|
|
});
|
|
}
|
|
|
|
const movedIds = move.emailIds.filter(id => !notMoved.has(id));
|
|
if (movedIds.length > 0) emitEmailMove({ ...move, emailIds: movedIds }, hookMailboxes);
|
|
// 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
|
|
});
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
batchArchive: async (client): Promise<boolean> => {
|
|
if (!isArchiveActionEnabled()) return false;
|
|
if (get().selectedEmailKeys.size === 0) return true;
|
|
if (isAggregateListView()) {
|
|
// The listed rows and the members of expanded threads the selection
|
|
// names; a selected row found in neither is reported (resolveSelectionRows).
|
|
const { rows, unknown } = resolveSelectionRows();
|
|
return archiveAggregateSelection(client, rows, unknown);
|
|
}
|
|
const { selectedMailbox } = get();
|
|
const selected = resolveSelectedEmails(get());
|
|
if (selected.length === 0) return true;
|
|
const listKeys = new Set(get().emails.map(emailKeyFor));
|
|
|
|
// A JMAP id is only unique within its owning account. Keep the reaching client in
|
|
// the grouping key as well as the owner account, including delegated owners.
|
|
type Group = ReturnType<typeof resolveEmailActionContext> & {
|
|
emails: Email[];
|
|
loginId: string | null;
|
|
stamped: boolean;
|
|
updatedIds: Set<string>;
|
|
createdArchive: boolean;
|
|
move?: Parameters<typeof allowEmailMove>[0];
|
|
hookMailboxes?: Mailbox[];
|
|
};
|
|
const groups: Group[] = [];
|
|
const failures: Error[] = [];
|
|
let stoppedByPlugin = false;
|
|
const sourceClients = new Map<string, IJMAPClient | undefined>();
|
|
const clientForSource = (loginId: string) => {
|
|
if (!sourceClients.has(loginId)) {
|
|
sourceClients.set(loginId, useAuthStore.getState().getClientForAccount(loginId));
|
|
}
|
|
return sourceClients.get(loginId);
|
|
};
|
|
const viewerLoginId = get().selectedEmail?.sourceClientAccountId;
|
|
if (viewerLoginId) clientForSource(viewerLoginId);
|
|
for (const email of selected) {
|
|
const stamped = !!(email.sourceClientAccountId && email.sourceAccountId);
|
|
if (stamped && !clientForSource(email.sourceClientAccountId!)) {
|
|
failures.push(new Error('Email account is not connected - cannot archive email'));
|
|
continue;
|
|
}
|
|
const context = resolveEmailActionContext(email, client);
|
|
// Merge stamped and unstamped rows of the same primary account too.
|
|
context.accountId ??= context.client.getAccountId?.()
|
|
|| context.mailboxes.find(m => !m.isShared)?.accountId;
|
|
const group = groups.find(g => g.client === context.client && g.accountId === context.accountId);
|
|
if (group) {
|
|
group.emails.push(email);
|
|
group.stamped ||= stamped;
|
|
} else groups.push({
|
|
...context, emails: [email], stamped, updatedIds: new Set(), createdArchive: false,
|
|
loginId: email.sourceClientAccountId ?? get().viewingAccountId ?? useAuthStore.getState().activeAccountId,
|
|
});
|
|
}
|
|
|
|
const isCurrentView = captureEmailListView();
|
|
const epoch = currentStoreEpoch();
|
|
const unstampedContext = resolveEmailActionContext({}, client);
|
|
const unstampedOwner = unstampedContext.accountId ?? unstampedContext.client.getAccountId?.()
|
|
?? unstampedContext.mailboxes.find(m => !m.isShared)?.accountId;
|
|
const groupForEmail = (email: Email) => {
|
|
const stamped = !!(email.sourceClientAccountId && email.sourceAccountId);
|
|
const actionClient = stamped
|
|
? clientForSource(email.sourceClientAccountId!)
|
|
: unstampedContext.client;
|
|
const owner = stamped ? email.sourceAccountId : unstampedOwner;
|
|
return groups.find(g => g.client === actionClient && g.accountId === owner);
|
|
};
|
|
const mode = useSettingsStore.getState().archiveMode;
|
|
set({ isLoading: true, error: null });
|
|
await Promise.all(groups.map(async group => {
|
|
try {
|
|
// The owner-only cache is ambiguous across servers/logins. Stamped
|
|
// selections resolve folders through their authenticated client instead.
|
|
let mailboxes = group.stamped
|
|
? await group.client.getMailboxes(group.accountId)
|
|
: group.mailboxes;
|
|
// A primary-account action can start with the merged sidebar list.
|
|
// Keep only this owner's folders; older own-folder objects may lack
|
|
// accountId, whereas delegated folders must always name their owner.
|
|
const primaryOwner = !group.accountId || group.accountId === group.client.getAccountId?.();
|
|
const ownerMailboxes = (folders: Mailbox[]) => folders
|
|
.filter(m => group.accountId
|
|
? m.accountId === group.accountId || (primaryOwner && !m.accountId && !m.isShared)
|
|
: !m.isShared)
|
|
.map(m => m.isShared ? {
|
|
...m, id: `${m.accountId}:${m.originalId || m.id}`,
|
|
originalId: m.originalId || m.id,
|
|
parentId: m.parentId && !m.parentId.startsWith(`${m.accountId}:`)
|
|
? `${m.accountId}:${m.parentId}` : m.parentId,
|
|
} : m.accountId ? m : { ...m, accountId: group.accountId });
|
|
mailboxes = ownerMailboxes(mailboxes);
|
|
const archive = await ensureArchiveMailbox({
|
|
client: group.client, mailboxes, selectedMailboxId: selectedMailbox,
|
|
accountId: group.accountId,
|
|
refresh: async () => {
|
|
mailboxes = ownerMailboxes(await group.client.getMailboxes(group.accountId));
|
|
group.createdArchive = !!findArchiveMailbox(mailboxes, selectedMailbox, group.accountId);
|
|
return mailboxes;
|
|
},
|
|
});
|
|
if (!archive) {
|
|
stoppedByPlugin = true;
|
|
return;
|
|
}
|
|
if (archive.myRights?.mayReadItems === false || archive.myRights?.mayAddItems === false) {
|
|
throw new ArchiveMailboxNotFoundError();
|
|
}
|
|
group.hookMailboxes = [...mailboxes, archive];
|
|
group.move = {
|
|
emailIds: [...new Set(group.emails.map(e => e.id))],
|
|
fromMailboxId: moveSourceMailboxId(group.emails, group.hookMailboxes),
|
|
toMailboxId: archive.id,
|
|
};
|
|
if (!(await allowEmailMove(group.move, group.hookMailboxes))) {
|
|
stoppedByPlugin = true;
|
|
return;
|
|
}
|
|
// Store-side shared folders may be namespaced; year/month lookup needs
|
|
// the server's bare parent ids so existing subfolders can be reused.
|
|
const jmapMailboxes = mailboxes.map(m => m.originalId ? {
|
|
...m, id: m.originalId,
|
|
parentId: m.parentId?.startsWith(`${m.accountId}:`)
|
|
? m.parentId.slice(`${m.accountId}:`.length) : m.parentId,
|
|
} : m);
|
|
await group.client.batchArchiveEmails(
|
|
group.emails.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
|
|
archive.originalId || archive.id, mode, jmapMailboxes, group.accountId ?? archive.accountId,
|
|
);
|
|
group.emails.forEach(e => group.updatedIds.add(e.id));
|
|
} catch (error) {
|
|
if (error instanceof ArchiveEmailsError) {
|
|
// Only accept confirmations belonging to this request's owner.
|
|
group.emails.forEach(e => {
|
|
if (error.updatedIds.includes(e.id)) group.updatedIds.add(e.id);
|
|
});
|
|
}
|
|
failures.push(error instanceof Error ? error : new Error('Failed to archive emails'));
|
|
}
|
|
if (group.move && group.hookMailboxes && group.updatedIds.size > 0) {
|
|
emitEmailMove({ ...group.move, emailIds: [...group.updatedIds] }, group.hookMailboxes);
|
|
}
|
|
}));
|
|
|
|
// Match the actual authenticated client and owner, including an unstamped
|
|
// list row whose open viewer carries source stamps for that same account.
|
|
const confirmedEmails = groups.flatMap(g => g.emails.filter(e => g.updatedIds.has(e.id)));
|
|
const wasArchived = (email: Email) => groupForEmail(email)?.updatedIds.has(email.id) ?? false;
|
|
const failedEmails = selected.filter(e => !wasArchived(e));
|
|
if (isCurrentView() && confirmedEmails.length > 0) {
|
|
set(state => {
|
|
const remaining = state.emails.filter(e => !wasArchived(e));
|
|
const remainingIds = new Set(remaining.map(e => e.id));
|
|
const selectedEmailKeys = new Set(state.selectedEmailKeys);
|
|
for (const email of confirmedEmails) selectedEmailKeys.delete(emailKeyFor(email));
|
|
const changedThreads = new Set(confirmedEmails.map(threadKeyFor));
|
|
state.emails.filter(wasArchived).forEach(e => {
|
|
changedThreads.add(threadKeyFor(e));
|
|
selectedEmailKeys.delete(emailKeyFor(e));
|
|
});
|
|
const remainingThreads = new Set(remaining.map(threadKeyFor));
|
|
const threadEmailsCache = new Map(state.threadEmailsCache);
|
|
for (const [key, members] of threadEmailsCache) {
|
|
const cached = members.filter(e => !wasArchived(e));
|
|
if (cached.length === members.length) continue;
|
|
changedThreads.add(key);
|
|
members.filter(wasArchived).forEach(e => selectedEmailKeys.delete(emailKeyFor(e)));
|
|
if (cached.length) threadEmailsCache.set(key, cached);
|
|
else threadEmailsCache.delete(key);
|
|
}
|
|
return {
|
|
emails: remaining,
|
|
totalEmails: Math.max(remaining.length, state.totalEmails - (state.emails.length - remaining.length)),
|
|
selectedEmailKeys,
|
|
selectedEmail: state.selectedEmail && wasArchived(state.selectedEmail) ? null : state.selectedEmail,
|
|
threadEmailsCache,
|
|
expandedThreadIds: new Set([...state.expandedThreadIds].filter(key =>
|
|
!changedThreads.has(key) || remainingThreads.has(key) || threadEmailsCache.has(key))),
|
|
retainedInViewIds: new Set([...state.retainedInViewIds].filter(id =>
|
|
!confirmedEmails.some(e => e.id === id) || remainingIds.has(id))),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Refresh every successful owner even if another owner (or another message
|
|
// in this owner's Email/set) failed. Also publish a newly created Archive
|
|
// when its email moves fail, as upstream's create-and-refresh path did.
|
|
await Promise.all(groups.filter(g => g.updatedIds.size > 0 || g.createdArchive).map(async group => {
|
|
try {
|
|
const fresh = await group.client.getMailboxes(group.accountId);
|
|
if (currentStoreEpoch() !== epoch || fresh.length === 0) return;
|
|
const owner = group.accountId ?? fresh[0]?.accountId;
|
|
const primary = group.client.getAccountId?.()
|
|
? group.client.getAccountId() === owner
|
|
: !fresh[0]?.isShared;
|
|
const normalized = fresh.filter(m => !owner || !m.accountId || m.accountId === owner).map(m => m.isShared ? {
|
|
...m, id: `${m.accountId}:${m.originalId || m.id}`,
|
|
originalId: m.originalId || m.id,
|
|
parentId: m.parentId && !m.parentId.startsWith(`${m.accountId}:`) ? `${m.accountId}:${m.parentId}` : m.parentId,
|
|
} : m);
|
|
set(state => {
|
|
const accountMailboxes = { ...state.accountMailboxes };
|
|
if (owner) accountMailboxes[owner] = normalized;
|
|
// Login caches may contain both personal and delegated folders.
|
|
// Replace this owner only, preserving the rest of that folder tree.
|
|
const mergeOwner = (folders: Mailbox[]) => [
|
|
...folders.filter(m => primary ? m.isShared : m.accountId !== owner), ...normalized,
|
|
];
|
|
if (group.loginId) {
|
|
accountMailboxes[group.loginId] = mergeOwner(state.accountMailboxes[group.loginId] ?? []);
|
|
}
|
|
const activeClient = useAuthStore.getState().getClientForAccount(useAuthStore.getState().activeAccountId ?? '');
|
|
return {
|
|
accountMailboxes,
|
|
mailboxes: activeClient === group.client
|
|
? mergeOwner(state.mailboxes)
|
|
: state.mailboxes,
|
|
};
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to refresh mailboxes after archive:', error);
|
|
failures.push(error instanceof Error ? error : new Error('Failed to refresh mailboxes after archive'));
|
|
}
|
|
}));
|
|
if (confirmedEmails.length > 0 && isCurrentView()) {
|
|
const failedKeys = new Set(failedEmails.map(emailKeyFor));
|
|
const failedRows = failedEmails.filter(e => listKeys.has(emailKeyFor(e)));
|
|
const failedThreads = [...get().threadEmailsCache].filter(([, members]) =>
|
|
members.some(e => failedKeys.has(emailKeyFor(e))));
|
|
try {
|
|
await get().refreshCurrentMailbox(client);
|
|
} catch (error) {
|
|
failures.push(error instanceof Error ? error : new Error('Failed to refresh emails after archive'));
|
|
}
|
|
if (isCurrentView() && failedEmails.length > 0) {
|
|
// A refreshed first page can omit a failed selected row (or return an
|
|
// equal raw id from another owner). Preserve failed objects and their
|
|
// account-aware selection, including selected expanded-thread members.
|
|
set(state => {
|
|
const sameEmail = (a: Email, b: Email) => emailKeyFor(a) === emailKeyFor(b);
|
|
const emails = state.emails.map(e => failedEmails.find(f => sameEmail(e, f)) ?? e);
|
|
for (const failed of failedRows) {
|
|
if (!emails.some(e => sameEmail(e, failed))) emails.push(failed);
|
|
}
|
|
const threadEmailsCache = new Map(state.threadEmailsCache);
|
|
for (const [key, members] of failedThreads) {
|
|
const merged = new Map(members.map(e => [emailKeyFor(e), e]));
|
|
for (const e of threadEmailsCache.get(key) ?? []) merged.set(emailKeyFor(e), e);
|
|
for (const e of failedEmails) {
|
|
if (threadKeyFor(e) === key) merged.set(emailKeyFor(e), e);
|
|
}
|
|
threadEmailsCache.set(key, [...merged.values()]);
|
|
}
|
|
return {
|
|
emails,
|
|
totalEmails: Math.max(state.totalEmails, emails.length),
|
|
selectedEmailKeys: new Set([...state.selectedEmailKeys, ...failedEmails.map(emailKeyFor)]),
|
|
threadEmailsCache,
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
// Publish after refresh so a successful refresh cannot erase the archive
|
|
// failure, and reject so the existing bulk-action toast reports it.
|
|
const error = failures.length === 1 ? failures[0]
|
|
: failures.length > 1 ? new Error(failures.map(e => e.message).join('; ')) : null;
|
|
if (isCurrentView()) set({ isLoading: false, error: error?.message ?? null });
|
|
if (error) throw error;
|
|
return !stoppedByPlugin;
|
|
},
|
|
|
|
// Spam operations
|
|
markAsSpam: async (client, emailId, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
// The viewer may show an email the list no longer holds (e.g. the unread
|
|
// quick-filter dropped it once it was read), so fall back to the selected
|
|
// email like moveThreadToMailbox does. Throw rather than no-op so the
|
|
// caller's success toast cannot fire for a message that was not moved. (#695)
|
|
const state = get();
|
|
const email = state.emails.find(isTarget)
|
|
?? ((!!state.selectedEmail && isTarget(state.selectedEmail)) ? state.selectedEmail : null);
|
|
if (!email) {
|
|
throw new Error('Email not found - cannot mark as spam');
|
|
}
|
|
if (emailHooks.onBeforeEmailSpamToggle.size > 0
|
|
&& !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: true }))) {
|
|
return false;
|
|
}
|
|
|
|
// In unified view route to the email's own account (client + that account's
|
|
// mailbox list); otherwise the active/viewing context. (#281)
|
|
const { client: actionClient, mailboxes, accountId } = resolveEmailActionContext(email, client);
|
|
|
|
// The email's current mailbox is what undo restores it to. In unified view
|
|
// derive it from the email's own folders (preferring the unified role),
|
|
// otherwise the active account's selected mailbox.
|
|
const currentMailbox = isAggregateListView()
|
|
? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole)
|
|
?? mailboxes.find(mb => emailInMailbox(email, mb)))
|
|
: mailboxes.find(m => m.id === get().selectedMailbox);
|
|
|
|
// Without a known source folder there is nothing for undo to restore to;
|
|
// still move the message, and undo falls back to the inbox. Returning
|
|
// here let the caller show a success toast for a message never moved.
|
|
if (currentMailbox) {
|
|
get().spamUndoCache.set(emailKey ?? emailId, {
|
|
emailId,
|
|
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
|
|
accountId,
|
|
sourceClientAccountId: email.sourceClientAccountId,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const isUnread = !email.keywords?.$seen;
|
|
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
|
|
await actionClient.markAsSpam(emailId, accountId, alsoMarkRead);
|
|
|
|
// Same junk resolution the client used for the move itself.
|
|
const junkMailbox = mailboxes.find(m =>
|
|
accountId ? (m.role === 'junk' && m.accountId === accountId) : (m.role === 'junk' && !m.isShared)
|
|
);
|
|
// After marking read in the same request, the email arrives in junk as read.
|
|
const arrivesUnread = isUnread && !alsoMarkRead;
|
|
|
|
set(state => {
|
|
// Counter changes go to the email's own account list; source and junk
|
|
// live in the same account. (#281)
|
|
const mailboxPatch = applyMailboxCounterUpdate(state, email, (mailbox) => {
|
|
if (emailInMailbox(email, mailbox)) {
|
|
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 (junkMailbox && mailbox.id === junkMailbox.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 => !isTarget(e)),
|
|
selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
|
|
...mailboxPatch,
|
|
};
|
|
});
|
|
void emailHooks.onEmailSpamToggle.emit({ emailIds: [emailId], spam: true });
|
|
} catch (error) {
|
|
console.error('Failed to mark as spam:', error);
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
undoSpam: async (client, emailId, emailKey): Promise<boolean> => {
|
|
const isTarget = singleEmailMatcher(emailId, emailKey);
|
|
if (emailHooks.onBeforeEmailSpamToggle.size > 0
|
|
&& !(await emailHooks.onBeforeEmailSpamToggle.intercept({ emailIds: [emailId], spam: false }))) {
|
|
return false;
|
|
}
|
|
const { selectedMailbox } = get();
|
|
const mailboxes = resolveActionMailboxes();
|
|
|
|
// Try cache first (preserves exact original mailbox for toast undo)
|
|
const cachedData = get().spamUndoCache.get(emailKey ?? emailId);
|
|
|
|
let targetMailboxId: string;
|
|
let accountId: string | undefined;
|
|
// In unified view the cache records the email's owning account so the undo
|
|
// is routed to that account's client, not the active one. (#281)
|
|
let undoClient: IJMAPClient = resolveActionClient(client);
|
|
|
|
if (cachedData) {
|
|
// Use cached original mailbox (more accurate for immediate undo)
|
|
targetMailboxId = cachedData.originalMailboxId;
|
|
accountId = cachedData.accountId;
|
|
if (cachedData.sourceClientAccountId) {
|
|
undoClient = stampedClient(cachedData.sourceClientAccountId);
|
|
}
|
|
get().spamUndoCache.delete(emailKey ?? emailId);
|
|
} else {
|
|
// Generic "not spam" (button/menu, no undo cache). In aggregate views
|
|
// (e.g. "All Junk") route to the email's own account so shared/group
|
|
// messages move back to *their* inbox via *their* client, not the active
|
|
// account's. (#281)
|
|
const listEmail = get().emails.find(isTarget);
|
|
let inboxMailboxes = mailboxes;
|
|
if (listEmail?.sourceClientAccountId && listEmail?.sourceAccountId) {
|
|
undoClient = stampedClient(listEmail.sourceClientAccountId);
|
|
accountId = listEmail.sourceAccountId;
|
|
inboxMailboxes = sourceMailboxes(get(), listEmail.sourceClientAccountId, listEmail.sourceAccountId);
|
|
} else {
|
|
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
|
accountId = currentMailbox?.accountId;
|
|
}
|
|
|
|
// Find inbox in same account
|
|
const inboxMailbox = inboxMailboxes.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 undoClient.undoSpam(emailId, targetMailboxId, accountId);
|
|
|
|
// Drop the restored mail from the current junk view and advance the open
|
|
// message to the next one, mirroring markAsSpam. Without this the viewer
|
|
// stays stuck on the mail that just left the folder.
|
|
set(state => ({
|
|
emails: state.emails.filter(e => !isTarget(e)),
|
|
selectedEmail: getNextSelectedEmailAfterRemovingRow(state, isTarget),
|
|
}));
|
|
void emailHooks.onEmailSpamToggle.emit({ emailIds: [emailId], spam: false });
|
|
|
|
// Refresh the view the user is actually looking at. If they leave it
|
|
// while the account list loads, skip the fetch rather than re-enter it.
|
|
if (get().isUnifiedView && get().crossView) {
|
|
const isSameView = captureViewToken();
|
|
const view = get().crossView!;
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const accounts = await buildUnifiedAccountClients({ includeGroup });
|
|
if (isSameView()) await get().fetchCrossView(accounts, view);
|
|
} else if (get().isUnifiedView && get().unifiedRole) {
|
|
const isSameView = captureViewToken();
|
|
const role = get().unifiedRole!;
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const accounts = await buildUnifiedAccountClients({ includeGroup });
|
|
if (isSameView()) await get().fetchUnifiedEmails(accounts, role);
|
|
} else {
|
|
await get().fetchEmails(client, selectedMailbox);
|
|
}
|
|
|
|
// Folder counts don't move on their own without a reliable JMAP push
|
|
// (often absent on subpath/cross-origin deploys), so refresh them here so
|
|
// the junk folder's badge drops right away.
|
|
void get().fetchMailboxes(client);
|
|
} catch (error) {
|
|
console.error('Failed to restore email:', error);
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
batchMarkAsSpam: async (client, emailIds): Promise<boolean> => {
|
|
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
|
return await runBatchSpamAction(get, set, client, emailIds, 'junk', (actionClient, email, accountId) =>
|
|
actionClient.markAsSpam(email.id, accountId, alsoMarkRead && !email.keywords?.$seen),
|
|
// After marking read in the same request, the email arrives in junk as read.
|
|
(email) => !email.keywords?.$seen && !alsoMarkRead,
|
|
);
|
|
},
|
|
|
|
// Batch "not spam" goes to the Inbox: unlike the toast undo there is no
|
|
// record of where each message came from.
|
|
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]): Promise<boolean> => {
|
|
return await runBatchSpamAction(get, set, client, emailIds, 'inbox', (actionClient, email, accountId, destination) =>
|
|
actionClient.undoSpam(email.id, destination!.originalId || destination!.id, accountId),
|
|
(email) => !email.keywords?.$seen,
|
|
);
|
|
},
|
|
|
|
// Push notification handlers
|
|
setPushConnected: (connected) => {
|
|
set({ isPushConnected: connected });
|
|
},
|
|
|
|
applyEmailDelta: (client, newState) => {
|
|
// Serialise deltas: two pushes in quick succession must apply in order,
|
|
// each against the sync state the previous one advanced to.
|
|
const run = emailDeltaQueue.then(() => applyEmailDeltaNow(client, newState, get, set), () => applyEmailDeltaNow(client, newState, get, set));
|
|
emailDeltaQueue = run.then(() => undefined, () => undefined);
|
|
return run;
|
|
},
|
|
|
|
applyMailboxDelta: async (client, changed) => {
|
|
if (!client.getMailboxChanges || !client.getMailboxesByIds) return false;
|
|
const states = { ...get().mailboxSyncStates };
|
|
let mailboxes = get().mailboxes;
|
|
if (mailboxes.length === 0) return false;
|
|
let patched = false;
|
|
|
|
for (const [acct, types] of Object.entries(changed)) {
|
|
const pushedState = types?.Mailbox;
|
|
if (!pushedState) continue;
|
|
const since = states[acct];
|
|
// Unknown baseline for this account (e.g. it appeared after the last
|
|
// full fetch) - the delta cannot be trusted, refetch everything.
|
|
if (!since) return false;
|
|
if (since === pushedState) continue;
|
|
|
|
const delta = await client.getMailboxChanges(since, acct);
|
|
// Folders created or destroyed change the tree shape (parents,
|
|
// selection validity, auto-created role folders) - let fetchMailboxes
|
|
// handle that path as before.
|
|
if (!delta || delta.hasMoreChanges || delta.created.length > 0 || delta.destroyed.length > 0) {
|
|
return false;
|
|
}
|
|
if (delta.updated.length > 0) {
|
|
const fresh = await client.getMailboxesByIds(delta.updated, acct);
|
|
const freshById = new Map(fresh.map((mb) => [mb.originalId ?? mb.id, mb]));
|
|
// An updated folder we don't have (or that vanished between the two
|
|
// calls) means the local list is out of step - fall back.
|
|
for (const id of delta.updated) {
|
|
const known = mailboxes.some((mb) => mb.accountId === acct && (mb.originalId ?? mb.id) === id);
|
|
if (!known || !freshById.has(id)) return false;
|
|
}
|
|
mailboxes = mailboxes.map((mb) =>
|
|
mb.accountId === acct ? (freshById.get(mb.originalId ?? mb.id) ?? mb) : mb,
|
|
);
|
|
patched = true;
|
|
}
|
|
states[acct] = delta.newState;
|
|
}
|
|
|
|
set({ mailboxes, mailboxSyncStates: states });
|
|
if (patched) {
|
|
// Same extension hook a full refresh publishes, so plugins that mirror
|
|
// folder counters see delta updates too.
|
|
if (emailHooks.onMailboxesRefresh.size > 0) await emailHooks.onMailboxesRefresh.emit(mailboxes.map(mailboxToView));
|
|
}
|
|
return true;
|
|
},
|
|
|
|
handleStateChange: async (change, client) => {
|
|
syncRuns += 1;
|
|
if (syncRuns === 1) void emailHooks.onSyncStateChange.emit({ state: 'syncing' });
|
|
let syncError: string | null = null;
|
|
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();
|
|
|
|
// New mail for plugins. Read the list's baseline before the delta
|
|
// below moves it past the new messages.
|
|
const pushedEmailState = change.changed[accountId]?.Email;
|
|
if (pushedEmailState) {
|
|
const listSync = get().emailListSync;
|
|
void reportNewInboxMail(
|
|
client, accountId, pushedEmailState,
|
|
listSync?.accountId === accountId ? listSync.state : undefined,
|
|
get,
|
|
);
|
|
}
|
|
|
|
// Changes may arrive for the client's primary account OR a delegated
|
|
// shared/group owner it has access to. Active-account *view* concerns
|
|
// (current email list, scheduled, calendar, filters) key off the primary
|
|
// account only, but the mailbox-COUNT refresh must react to any changed
|
|
// account: the active client's getAllMailboxes returns own + delegated
|
|
// folders, and the unified-section counts project from that list. (#281)
|
|
const accountChanges = change.changed[accountId];
|
|
const anyMailboxChanged = Object.values(change.changed).some((c) => c?.Mailbox);
|
|
|
|
// Handle Email state changes. A plain folder list is first resolved as
|
|
// an Email/changes delta (RFC 8620 §5.2) against the state it was
|
|
// loaded at - which also covers a shared folder whose owner account
|
|
// changed - and only re-queried when the delta cannot be applied.
|
|
const sync = get().emailListSync;
|
|
const syncedAccountEmailState = sync ? change.changed[sync.accountId]?.Email : undefined;
|
|
// A tag view and the sidebar tag badges span the own AND the group/shared
|
|
// accounts (#1038), so an Email change in any of them concerns them.
|
|
const anyEmailChanged = Object.values(change.changed).some((c) => c?.Email);
|
|
const tagViewChanged = !!get().selectedKeyword && anyEmailChanged;
|
|
// So does an "All folders" search (#1082).
|
|
const unscopedSearchChanged = isUnscopedSearchActive(get()) && anyEmailChanged;
|
|
if (accountChanges?.Email || syncedAccountEmailState || tagViewChanged || unscopedSearchChanged) {
|
|
// A delta that could not be read falls back to the full refresh.
|
|
const applied = syncedAccountEmailState
|
|
? await get().applyEmailDelta(client, syncedAccountEmailState).catch(() => false)
|
|
: false;
|
|
if (!applied) {
|
|
await get().refreshCurrentMailbox(client);
|
|
}
|
|
if (anyEmailChanged) {
|
|
get().fetchTagCounts(client);
|
|
}
|
|
}
|
|
|
|
if (accountChanges?.EmailSubmission) {
|
|
await get().refreshScheduledMetadata(client);
|
|
if (get().isScheduledView) {
|
|
await get().fetchScheduledEmails(client);
|
|
}
|
|
}
|
|
|
|
// Handle Mailbox state changes - refresh mailbox list (own + delegated
|
|
// shared folders), so both the active account's and its shared folders'
|
|
// counters follow background activity.
|
|
if (anyMailboxChanged) {
|
|
const applied = await get().applyMailboxDelta(client, change.changed);
|
|
if (!applied) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Someone changed this user's access to one of their collections
|
|
// (RFC 9670 ShareNotification): pull the notifications, the toaster
|
|
// shows them and refreshes the affected list.
|
|
if (Object.values(change.changed).some((c) => c?.ShareNotification)) {
|
|
const { useShareNotificationStore } = await import('./share-notification-store');
|
|
void useShareNotificationStore.getState().fetch(client);
|
|
}
|
|
|
|
// Another participant invited this user to, changed or cancelled an
|
|
// event (CalendarEventNotification): same pull-and-toast flow.
|
|
if (Object.values(change.changed).some((c) => c?.CalendarEventNotification)) {
|
|
const { useCalendarEventNotificationStore } = await import('./calendar-event-notification-store');
|
|
void useCalendarEventNotificationStore.getState().fetch(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);
|
|
syncError = error instanceof Error ? error.message : "Failed to handle push notification";
|
|
set({ error: syncError });
|
|
} finally {
|
|
syncRuns -= 1;
|
|
if (syncError !== null) {
|
|
void emailHooks.onSyncStateChange.emit({ state: 'error', error: syncError });
|
|
} else if (syncRuns === 0) {
|
|
void emailHooks.onSyncStateChange.emit({ state: 'idle' });
|
|
}
|
|
}
|
|
},
|
|
|
|
refreshCurrentMailbox: (client) => coalesceRefresh(client, 'currentMailbox', async () => {
|
|
const { selectedMailbox, selectedKeyword } = get();
|
|
const isCurrentView = captureEmailListView();
|
|
|
|
// Labels span folders and can be selected without a mailbox.
|
|
if (!selectedMailbox && !selectedKeyword) return;
|
|
|
|
if (!selectedKeyword && selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) {
|
|
await get().fetchScheduledEmails(client);
|
|
return;
|
|
}
|
|
|
|
// The rows shown before the query goes out; see removedWhileQuerying.
|
|
const listedBeforeQuery = get().emails.map(e => e.id);
|
|
|
|
try {
|
|
// 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, isUnifiedView, unifiedRole, crossView } = get();
|
|
const hasFilters = !isFilterEmpty(searchFilters);
|
|
|
|
// The unified ("All Mail") and cross-account views hold a VIRTUAL
|
|
// selectedMailbox id (e.g. __cross_all__ / __unified_inbox__) that no real
|
|
// folder matches. Feeding it to getEmails as an inMailbox filter returns an
|
|
// empty page, and the merge below would then wipe the whole aggregated list
|
|
// on every push - and any delete/star/read raises an Email state change.
|
|
// Fan out via the same loaders the initial load and loadMore use so the
|
|
// aggregated list survives a refresh. `mailbox` stays undefined for these
|
|
// views; it only gates the inbox new-mail notification, which the unified
|
|
// views don't raise.
|
|
let result;
|
|
let mailbox;
|
|
let unifiedErrors: Map<string, string> | undefined;
|
|
let syncAfterRefresh: EmailListSync | null = null;
|
|
// How many rows the refresh re-queries from the top of the view.
|
|
let windowSize = emailsPerPage;
|
|
// Set when the whole loaded list was re-queried in a keyword order.
|
|
let keepOpenThread = false;
|
|
if (!selectedKeyword && isUnifiedView && crossView) {
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
result = hasFilters
|
|
? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0)
|
|
: searchQuery
|
|
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0)
|
|
: await fetchCrossViewEmails(built, crossView, emailsPerPage, 0);
|
|
unifiedErrors = result.errors;
|
|
} else if (!selectedKeyword && isUnifiedView && unifiedRole) {
|
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
|
result = hasFilters
|
|
? await advancedSearchUnifiedEmails(built, unifiedRole, (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), emailsPerPage, 0)
|
|
: searchQuery
|
|
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, 0)
|
|
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, 0, getMessageListOrderFor(unifiedRole));
|
|
unifiedErrors = result.errors;
|
|
} else {
|
|
// Single real mailbox (own or shared): query by its JMAP id. Refresh the
|
|
// list without clearing it first for a smoother update experience.
|
|
const mailboxes = resolveActionMailboxes();
|
|
const effectiveClient = resolveActionClient(client);
|
|
mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
|
if (selectedKeyword) {
|
|
// Match fetchEmails: tag views take precedence and query the label
|
|
// across all folders and across the own + group accounts (#1038).
|
|
// They cannot establish a folder delta baseline.
|
|
// An active search narrows the tag, so carry it or the refresh
|
|
// would silently widen the list back to the whole tag.
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await fetchTagEmails(
|
|
built, `$label:${selectedKeyword}`, emailsPerPage, 0, getMessageListOrderFor(null),
|
|
hasFilters
|
|
? buildJMAPFilter(searchQuery, searchFilters, undefined)
|
|
: searchQuery
|
|
? textFilter(searchQuery)
|
|
: undefined,
|
|
);
|
|
unifiedErrors = result.errors;
|
|
} else if (hasFilters || searchQuery) {
|
|
// A refresh while a search is active must re-run it under the
|
|
// search's own folder scope, which is independent of selectedMailbox.
|
|
const { searchMailboxId } = get();
|
|
if (isAllFoldersSearchScope(searchMailboxId)) {
|
|
// Folder-less scope: the same own + group account fan-out the
|
|
// search ran, so a change in a group account reaches the list (#1082).
|
|
const built = buildTagViewAccountClients(client);
|
|
result = await advancedSearchAcrossAccounts(
|
|
built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
|
|
acrossAccountsSearchOptions(searchMailboxId),
|
|
);
|
|
unifiedErrors = result.errors;
|
|
} else {
|
|
const scopeMailbox = mailboxes.find(mb => mb.id === searchMailboxId);
|
|
const filter = buildJMAPFilter(searchQuery, searchFilters, scopeMailbox?.originalId || searchMailboxId);
|
|
const scopeAccountId = scopeMailbox?.isShared ? scopeMailbox.accountId : undefined;
|
|
result = await effectiveClient.advancedSearchEmails(filter, scopeAccountId, emailsPerPage, 0);
|
|
}
|
|
} else {
|
|
const order = getMessageListOrderFor(mailbox?.role);
|
|
// A keyword order (#718) moves rows whose read/star/tag state
|
|
// changed - possibly past the end of what is loaded - and rows from
|
|
// beyond it up. Re-query every loaded row, not only the first page,
|
|
// so the list stays the head of the server order. The rows kept
|
|
// out of that order below are not part of the head.
|
|
if (orderKeywords(order).length > 0) {
|
|
const { emails: loaded, retainedInViewIds, unreadSection } = get();
|
|
const inOrder = loaded.filter(e => !retainedInViewIds.has(e.id) && !unreadSection?.ids.has(e.id)).length;
|
|
windowSize = Math.min(Math.max(emailsPerPage, inOrder), effectiveClient.getMaxObjectsInGet());
|
|
keepOpenThread = true;
|
|
}
|
|
// The open category tab filters the refresh like the first load and
|
|
// pagination, or other tabs' mail would merge into the list (#1264).
|
|
const categoryFilter = folderListFilter(mailbox, get());
|
|
result = await effectiveClient.getEmails(jmapMailboxId, accountId, windowSize, 0, undefined, true, categoryFilter ?? undefined, order);
|
|
// This page is the new delta-sync baseline for the folder; the
|
|
// search / unified paths above cannot be delta-synced, and neither
|
|
// can a category tab's page (see fetchEmails).
|
|
syncAfterRefresh = result.state && !categoryFilter
|
|
? { state: result.state, accountId: accountId ?? effectiveClient.getAccountId(), mailboxId: selectedMailbox }
|
|
: null;
|
|
}
|
|
}
|
|
if (!isCurrentView()) return;
|
|
set({ emailListSync: syncAfterRefresh });
|
|
|
|
// Rows that left the list while the query was out were deleted, moved
|
|
// or filed away meanwhile, and the server may have answered before that
|
|
// change landed. Merging them back made a mail deleted in quick
|
|
// succession reappear until the next push (#966).
|
|
const removedWhileQuerying = new Set(listedBeforeQuery);
|
|
for (const email of get().emails) removedWhileQuerying.delete(email.id);
|
|
if (removedWhileQuerying.size > 0) {
|
|
const page = result.emails.filter(e => !removedWhileQuerying.has(e.id));
|
|
const dropped = result.emails.length - page.length;
|
|
result = { ...result, emails: page, total: Math.max(0, (result.total || 0) - dropped) };
|
|
}
|
|
|
|
const currentEmails = get().emails;
|
|
const previousTotal = get().totalEmails;
|
|
const insertedCount = Math.max((result.total || 0) - previousTotal, 0);
|
|
|
|
// 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.
|
|
// Pinned mails sit above the date order, and a configured list order
|
|
// (#718, e.g. starred first) can put older mail on top too, so the
|
|
// candidate is the newest non-pinned mail on the page rather than its
|
|
// first entry (a just-arrived mail cannot be pinned yet). Deleting the
|
|
// top message shifts in an OLDER one, which never becomes the newest.
|
|
// Re-querying a keyword order can also move up a newer mail that was not
|
|
// loaded yet (e.g. unread first, the newest read mail); that is only
|
|
// new mail when the folder grew.
|
|
const newFirst = result.emails
|
|
.filter(e => !e.keywords?.['$pinned'])
|
|
.reduce<Email | undefined>(
|
|
(newest, e) => (!newest || new Date(e.receivedAt).getTime() > new Date(newest.receivedAt).getTime() ? e : newest),
|
|
undefined,
|
|
) ?? result.emails[0];
|
|
if (
|
|
newFirst &&
|
|
!selectedKeyword &&
|
|
mailbox?.role === 'inbox' &&
|
|
!currentEmails.some(e => e.id === newFirst.id) &&
|
|
(!keepOpenThread || insertedCount > 0)
|
|
) {
|
|
notifyNewInboxMail(client, mailbox, newFirst, get);
|
|
}
|
|
|
|
// 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.
|
|
let merged: Email[] = [...refreshedEmails];
|
|
const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id));
|
|
// Derive the cutoff from the page size, not from the refreshed list's
|
|
// length: when the folder shrank (a deletion - e.g. the draft of a just
|
|
// sent mail), the fresh page is shorter than the stale list and a
|
|
// length-based cutoff re-appends the deleted rows from stale local
|
|
// state. That ghost row is how "sent mail still shows as draft"
|
|
// reports happen (#592) - and re-sending the ghost delivers the mail
|
|
// again.
|
|
const appendFromIndex = Math.max(windowSize - insertedCount, 0);
|
|
// Rows kept out of a keyword order are not part of the re-queried head
|
|
// and are decided on below.
|
|
const previouslyRetained = get().retainedInViewIds;
|
|
// Nor are the Unread section's; they go back in front below, until
|
|
// the section is loaded again. A row of it the fresh page has (read
|
|
// since) is the page's now.
|
|
const unreadSection = withoutReached(get().unreadSection, mergedIds);
|
|
const sectionRows = unreadSection ? currentEmails.filter(e => unreadSection.ids.has(e.id)) : [];
|
|
const inOrderEmails = (keepOpenThread || sectionRows.length > 0)
|
|
? currentEmails.filter(e => !(keepOpenThread && previouslyRetained.has(e.id)) && !unreadSection?.ids.has(e.id))
|
|
: currentEmails;
|
|
|
|
for (const email of inOrderEmails.slice(appendFromIndex)) {
|
|
if (!mergedIds.has(email.id)) {
|
|
merged.push(email);
|
|
mergedIds.add(email.id);
|
|
}
|
|
}
|
|
|
|
// Rows that left the re-queried head of a keyword order are dropped;
|
|
// load-more brings them back where they now belong. Not the open
|
|
// conversation's, though: every action on an email looks it up in this
|
|
// list, so "mark as unread" on the mail just read must still find it.
|
|
// Nor the held row, which stays where it was clicked. They stay until
|
|
// something else is opened, and load-more does not count them.
|
|
const held = get().listHold?.keywords;
|
|
let retainedInViewIds: Set<string> | undefined;
|
|
if (keepOpenThread) {
|
|
const open = get().selectedEmail;
|
|
const openThread = open ? threadKeyFor(open) : null;
|
|
retainedInViewIds = new Set();
|
|
for (const email of currentEmails) {
|
|
if (mergedIds.has(email.id)) continue;
|
|
if (threadKeyFor(email) !== openThread && !held?.has(email.id)) continue;
|
|
merged.push(email);
|
|
mergedIds.add(email.id);
|
|
retainedInViewIds.add(email.id);
|
|
}
|
|
}
|
|
|
|
// Keep rows the user just read/unstarred in the unread/starred cross view:
|
|
// the fresh keyword-filtered page dropped them, but they should stay put
|
|
// (their status + the counters already updated in the action) until the
|
|
// view is re-opened. Only applies to those two views; the retain set is
|
|
// empty otherwise.
|
|
let retainedAddedCount = 0;
|
|
if (retainingCrossView({ isUnifiedView, crossView })) {
|
|
const beforeLen = merged.length;
|
|
merged = mergeRetainedRows(currentEmails, merged, get().retainedInViewIds);
|
|
retainedAddedCount = merged.length - beforeLen;
|
|
}
|
|
|
|
// The fresh order has the held row where its new read/star/tag state
|
|
// sorts; put it back where the list shows it (the order EmailList uses).
|
|
merged = placeHeldRows(merged, searchQuery || crossView || hasFilters ? [] : get().listOrder, held);
|
|
if (sectionRows.length > 0) merged = [...sectionRows, ...merged];
|
|
|
|
// 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) {
|
|
// Retained rows sit beyond the server's keyword-filtered count, so add
|
|
// them to the total; otherwise the list would claim fewer items than it
|
|
// shows and mis-compute hasMore.
|
|
const effectiveTotal = (result.total || 0) + retainedAddedCount;
|
|
// hasMore should reflect whether there are still more emails beyond
|
|
// what we have loaded, using the fresh total from the server. The
|
|
// Unread section's rows are not part of that total.
|
|
const hasMore = merged.length - sectionRows.length < effectiveTotal;
|
|
|
|
// Invalidate thread email caches for threads whose composition changed
|
|
// so expanded threads pick up new/removed emails.
|
|
const prevThreadIds = new Set(currentEmails.map(e => threadKeyFor(e)));
|
|
const nextThreadIds = new Set(merged.map(e => threadKeyFor(e)));
|
|
const changedThreadIds = new Set<string>();
|
|
for (const tid of prevThreadIds) {
|
|
if (!nextThreadIds.has(tid)) changedThreadIds.add(tid);
|
|
}
|
|
for (const tid of nextThreadIds) {
|
|
if (!prevThreadIds.has(tid)) changedThreadIds.add(tid);
|
|
}
|
|
// Also check threads where the set of email IDs changed
|
|
const prevEmailsByThread = new Map<string, Set<string>>();
|
|
for (const e of currentEmails) {
|
|
const key = threadKeyFor(e);
|
|
if (!prevEmailsByThread.has(key)) prevEmailsByThread.set(key, new Set());
|
|
prevEmailsByThread.get(key)!.add(e.id);
|
|
}
|
|
const nextEmailsByThread = new Map<string, Set<string>>();
|
|
for (const e of merged) {
|
|
const key = threadKeyFor(e);
|
|
if (!nextEmailsByThread.has(key)) nextEmailsByThread.set(key, new Set());
|
|
nextEmailsByThread.get(key)!.add(e.id);
|
|
}
|
|
for (const [tid, nextIds] of nextEmailsByThread) {
|
|
const prevIds = prevEmailsByThread.get(tid);
|
|
if (!prevIds || prevIds.size !== nextIds.size) {
|
|
changedThreadIds.add(tid);
|
|
} else {
|
|
for (const id of nextIds) {
|
|
if (!prevIds.has(id)) { changedThreadIds.add(tid); break; }
|
|
}
|
|
}
|
|
}
|
|
|
|
set((state) => {
|
|
const newCache = new Map(state.threadEmailsCache);
|
|
for (const tid of changedThreadIds) {
|
|
newCache.delete(tid);
|
|
}
|
|
return {
|
|
emails: merged,
|
|
hasMoreEmails: hasMore,
|
|
totalEmails: effectiveTotal,
|
|
threadEmailsCache: newCache,
|
|
...(unifiedErrors !== undefined ? { unifiedErrors } : {}),
|
|
...(retainedInViewIds ? { retainedInViewIds } : {}),
|
|
unreadSection,
|
|
};
|
|
});
|
|
|
|
// Re-fetch cross-folder thread data for any currently expanded threads
|
|
// so they show the complete conversation (not just current-folder emails).
|
|
const expandedNow = get().expandedThreadIds;
|
|
if (expandedNow.size > 0) {
|
|
const effectiveClient2 = resolveActionClient(client);
|
|
const accountId = effectiveClient2.getAccountId();
|
|
for (const tid of expandedNow) {
|
|
void effectiveClient2.getThreadEmails(tid, accountId).then((fullEmails) => {
|
|
if (fullEmails.length > 0) {
|
|
set((state) => {
|
|
const c = new Map(state.threadEmailsCache);
|
|
c.set(tid, fullEmails);
|
|
return { threadEmailsCache: c };
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Fetch full thread counts in the background (non-blocking)
|
|
void get().fetchThreadEmailCounts(client);
|
|
} else if (retainedInViewIds) {
|
|
set({ retainedInViewIds });
|
|
}
|
|
|
|
// The Unread section is loaded again too; new mail lands there.
|
|
if (mailbox && !selectedKeyword && unreadFirstFor(mailbox, get())) {
|
|
const loaded = await loadUnreadSection(client, mailbox, get);
|
|
if (!isCurrentView()) return;
|
|
const listed = new Set(get().emails.map(e => e.id));
|
|
const newest = loaded.emails.find(e => !e.keywords?.['$pinned']);
|
|
if (newest && !listed.has(newest.id) && loaded.total > (get().unreadSection?.total ?? 0)) {
|
|
notifyNewInboxMail(client, mailbox, newest, get);
|
|
}
|
|
applyUnreadSection(get, set, selectedMailbox, loaded);
|
|
}
|
|
} 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: (threadKey) => {
|
|
const { expandedThreadIds } = get();
|
|
const newExpandedThreadIds = new Set(expandedThreadIds);
|
|
|
|
if (newExpandedThreadIds.has(threadKey)) {
|
|
newExpandedThreadIds.delete(threadKey);
|
|
cancelPendingThreadRead(threadKey);
|
|
} else {
|
|
newExpandedThreadIds.add(threadKey);
|
|
// Expanding may mark the thread read; hold it before that lands.
|
|
get().holdListRow(threadKey);
|
|
}
|
|
|
|
set({ expandedThreadIds: newExpandedThreadIds });
|
|
},
|
|
|
|
fetchThreadEmails: async (client, threadKey) => {
|
|
const { threadEmailsCache, selectedMailbox } = get();
|
|
const mailboxes = resolveActionMailboxes();
|
|
|
|
// Check if we already have this thread cached
|
|
const cachedEmails = threadEmailsCache.get(threadKey);
|
|
if (cachedEmails && cachedEmails.length > 0) {
|
|
return cachedEmails;
|
|
}
|
|
|
|
// Set loading state
|
|
set({ isLoadingThread: threadKey });
|
|
|
|
try {
|
|
// Route to the thread's own account. In aggregate views `selectedMailbox`
|
|
// is virtual, so derive the client + accountId from a list email of this
|
|
// thread (handles shared/group accounts); otherwise fall back to the
|
|
// selected-mailbox shared-folder logic. (#281) The key is account-scoped,
|
|
// so this can't pick up another account's thread with the same id.
|
|
const threadEmail = get().emails.find(e => threadKeyFor(e) === threadKey);
|
|
const threadId = threadEmail?.threadId ?? threadIdFromKey(threadKey);
|
|
const route = resolveThreadRoute({
|
|
isUnifiedView: isAggregateListView(),
|
|
ref: threadEmail,
|
|
mailboxes,
|
|
selectedMailbox,
|
|
});
|
|
let actionClient = resolveActionClient(client);
|
|
if (route.clientAccountId) {
|
|
actionClient = useAuthStore.getState().getClientForAccount(route.clientAccountId) ?? actionClient;
|
|
}
|
|
|
|
// Fetch all emails in the thread
|
|
const emails = await actionClient.getThreadEmails(threadId, route.accountId);
|
|
|
|
// Re-stamp the source reference so actions on thread emails resolve to the
|
|
// right account (the fetched objects don't carry it).
|
|
if (isAggregateListView() && threadEmail) {
|
|
for (const e of emails) {
|
|
e.accountId = threadEmail.accountId;
|
|
e.accountLabel = threadEmail.accountLabel;
|
|
e.sourceClientAccountId = threadEmail.sourceClientAccountId;
|
|
e.sourceAccountId = threadEmail.sourceAccountId;
|
|
}
|
|
}
|
|
|
|
// Update cache
|
|
const newCache = new Map(get().threadEmailsCache);
|
|
newCache.set(threadKey, emails);
|
|
|
|
set({
|
|
threadEmailsCache: newCache,
|
|
isLoadingThread: null
|
|
});
|
|
|
|
return emails;
|
|
} catch (error) {
|
|
console.error('Failed to fetch thread emails:', error);
|
|
set({ isLoadingThread: null });
|
|
return [];
|
|
}
|
|
},
|
|
|
|
autoMarkExpandedThreadAsRead: (client, threadKey) => {
|
|
cancelPendingThreadRead(threadKey);
|
|
const { markAsReadDelay } = useSettingsStore.getState();
|
|
const cancel = scheduleAutoMarkAsRead(markAsReadDelay, () => {
|
|
pendingThreadReads.delete(threadKey);
|
|
// Collapsed (or the list was reset) before the delay ran out.
|
|
if (!get().expandedThreadIds.has(threadKey)) return;
|
|
void get().markThreadAsRead(client, threadKey);
|
|
});
|
|
if (markAsReadDelay > 0) pendingThreadReads.set(threadKey, cancel);
|
|
},
|
|
|
|
cancelAutoMarkThreadAsRead: (threadKey) => {
|
|
cancelPendingThreadRead(threadKey);
|
|
},
|
|
|
|
markThreadAsRead: async (client, threadKey) => {
|
|
const state = get();
|
|
const threadEmails = state.threadEmailsCache.get(threadKey) ?? [];
|
|
const mainEmails = state.emails.filter(e => threadKeyFor(e) === threadKey);
|
|
|
|
// Combine unique emails from both sources
|
|
const allEmailMap = new Map<string, Email>();
|
|
for (const e of mainEmails) allEmailMap.set(e.id, e);
|
|
for (const e of threadEmails) allEmailMap.set(e.id, e);
|
|
|
|
const unreadIds = Array.from(allEmailMap.values())
|
|
.filter(e => !e.keywords?.$seen)
|
|
.map(e => e.id);
|
|
|
|
if (unreadIds.length === 0) return;
|
|
if (emailHooks.onBeforeEmailReadStateChange.size > 0
|
|
&& !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [...unreadIds], read: true }))) {
|
|
return;
|
|
}
|
|
|
|
// Group the unread ids by the client and the JMAP account that own them.
|
|
// Ids repeat across accounts (#847): a write without the owner's account
|
|
// id lands in the login's own account, where the server answers notFound
|
|
// or marks an unrelated message that happens to share the id. So a thread
|
|
// in a shared or group folder has to name its account.
|
|
const groups = new Map<IJMAPClient, Map<string | undefined, string[]>>();
|
|
try {
|
|
for (const id of unreadIds) {
|
|
const { client: actionClient, accountId } = resolveEmailActionContext(allEmailMap.get(id)!, client);
|
|
const byAccount = groups.get(actionClient) ?? new Map<string | undefined, string[]>();
|
|
byAccount.set(accountId, [...(byAccount.get(accountId) ?? []), id]);
|
|
groups.set(actionClient, byAccount);
|
|
}
|
|
} catch (error) {
|
|
// A message of a login that is gone: the thread stays unread.
|
|
console.error('Failed to mark thread as read:', error);
|
|
return;
|
|
}
|
|
|
|
// Mark all as read on the server. A message the server refused (a
|
|
// read-only shared folder) stays unread; the others are marked.
|
|
const writes = Array.from(groups.entries()).flatMap(([actionClient, byAccount]) =>
|
|
Array.from(byAccount.entries()).map(([accountId, emailIds]) => ({
|
|
emailIds,
|
|
done: actionClient.batchMarkAsRead(emailIds, true, accountId),
|
|
}))
|
|
);
|
|
const outcomes = await Promise.allSettled(writes.map(({ done }) => done));
|
|
const refused = new Set<string>();
|
|
for (let i = 0; i < outcomes.length; i++) {
|
|
const outcome = outcomes[i];
|
|
if (outcome.status === 'fulfilled') continue;
|
|
console.error('Failed to mark thread as read:', outcome.reason);
|
|
for (const id of jmapErrorFailedIds(outcome.reason) ?? writes[i].emailIds) refused.add(id);
|
|
}
|
|
const markedIds = unreadIds.filter(id => !refused.has(id));
|
|
if (markedIds.length === 0) return;
|
|
|
|
// Update local state
|
|
set((state) => {
|
|
const unreadSet = new Set(markedIds);
|
|
|
|
const updatedEmails = state.emails.map(e =>
|
|
unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
|
|
);
|
|
|
|
// Update threadEmailsCache
|
|
const newCache = new Map(state.threadEmailsCache);
|
|
const cached = newCache.get(threadKey);
|
|
if (cached) {
|
|
newCache.set(threadKey, cached.map(e =>
|
|
unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e
|
|
));
|
|
}
|
|
|
|
// Update mailbox unread counters in the thread's own account list (#281).
|
|
const affectedEmails = state.emails.filter(e => unreadSet.has(e.id));
|
|
const mailboxPatch = applyBatchMailboxCounterUpdate(state, affectedEmails, (mailbox, group) => {
|
|
let delta = 0;
|
|
for (const email of group as Email[]) {
|
|
if (emailInMailbox(email, mailbox)) delta -= 1;
|
|
}
|
|
return delta === 0 ? mailbox : {
|
|
...mailbox,
|
|
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
|
|
unreadThreads: Math.max(0, mailbox.unreadThreads + delta),
|
|
};
|
|
});
|
|
|
|
return {
|
|
emails: updatedEmails,
|
|
threadEmailsCache: newCache,
|
|
...mailboxPatch,
|
|
selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id)
|
|
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
|
|
: state.selectedEmail,
|
|
};
|
|
});
|
|
void emailHooks.onEmailReadStateChange.emit({ emailIds: [...unreadIds], read: true });
|
|
refillAfterKeywordChange(get, client, ['$seen']);
|
|
},
|
|
|
|
collapseAllThreads: () => {
|
|
cancelPendingThreadRead();
|
|
set({
|
|
expandedThreadIds: new Set(),
|
|
isLoadingThread: null
|
|
});
|
|
},
|
|
|
|
updateThreadCache: (threadId, emails) => {
|
|
const newCache = new Map(get().threadEmailsCache);
|
|
newCache.set(threadId, emails);
|
|
set({ threadEmailsCache: newCache });
|
|
},
|
|
|
|
fetchThreadEmailCounts: (client) => coalesceRefresh(client, 'threadCounts', async () => {
|
|
const { emails } = get();
|
|
if (emails.length === 0) return;
|
|
|
|
// Thread ids are per-account, so ask each source account for its own
|
|
// threads and file the counts under the account-scoped key. One client
|
|
// answering for every id would return the ACTIVE account's unrelated
|
|
// thread "b" for every other account's thread "b" in an aggregate view.
|
|
const bySource = new Map<string, { ref: Email; threadIds: Set<string> }>();
|
|
for (const e of emails) {
|
|
if (!e.threadId) continue;
|
|
const source = `${e.sourceClientAccountId ?? ''}/${e.sourceAccountId ?? ''}`;
|
|
const entry = bySource.get(source) ?? { ref: e, threadIds: new Set<string>() };
|
|
entry.threadIds.add(e.threadId);
|
|
bySource.set(source, entry);
|
|
}
|
|
if (bySource.size === 0) return;
|
|
|
|
try {
|
|
const newCounts = new Map(get().threadEmailCounts);
|
|
for (const { ref, threadIds } of bySource.values()) {
|
|
const { client: sourceClient, accountId } = resolveEmailActionContext(ref, client);
|
|
const threads = await sourceClient.getThreads([...threadIds], accountId);
|
|
for (const thread of threads) {
|
|
const key = threadKeyFor({
|
|
threadId: thread.id,
|
|
sourceClientAccountId: ref.sourceClientAccountId,
|
|
sourceAccountId: ref.sourceAccountId,
|
|
});
|
|
newCounts.set(key, thread.emailIds?.length ?? 0);
|
|
}
|
|
}
|
|
set({ threadEmailCounts: newCounts });
|
|
} catch {
|
|
// Non-critical — fall back to inbox-only counts
|
|
}
|
|
}),
|
|
|
|
// Mailbox management
|
|
createMailbox: async (client, name, parentId, accountId): Promise<boolean> => {
|
|
try {
|
|
// Root creation is personal unless its UI caller explicitly supplies a
|
|
// shared/group account (the account header's context menu). Never infer
|
|
// a root target merely from whichever mailbox happens to be selected.
|
|
const target = parentId
|
|
? resolveMailboxMutationContext(client, parentId)
|
|
: { ...resolveMailboxAccountTarget(client, accountId), mailboxId: undefined };
|
|
if (emailHooks.onBeforeMailboxCreate.size > 0
|
|
&& !(await emailHooks.onBeforeMailboxCreate.intercept({ name, parentId: parentId ?? null }))) {
|
|
return false;
|
|
}
|
|
const created = await target.client.createMailbox(name, target.mailboxId, target.accountId);
|
|
void emailHooks.onMailboxCreate.emit({ mailboxId: created?.id ?? null, name, parentId: parentId ?? null });
|
|
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;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
renameMailbox: async (client, mailboxId, name): Promise<boolean> => {
|
|
try {
|
|
const target = resolveMailboxMutationContext(client, mailboxId);
|
|
if (emailHooks.onBeforeMailboxRename.size > 0
|
|
&& !(await emailHooks.onBeforeMailboxRename.intercept({ mailboxId, name }))) {
|
|
return false;
|
|
}
|
|
const previousName = resolveActionMailboxes().find(mb => mb.id === mailboxId)?.name ?? null;
|
|
await target.client.updateMailbox(target.mailboxId, { name }, target.accountId);
|
|
void emailHooks.onMailboxRename.emit({ mailboxId, name, previousName });
|
|
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;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
deleteMailbox: async (client, mailboxId, options): Promise<boolean> => {
|
|
try {
|
|
const target = resolveMailboxMutationContext(client, mailboxId);
|
|
const deletedName = resolveActionMailboxes().find(mb => mb.id === mailboxId)?.name ?? null;
|
|
if (emailHooks.onBeforeMailboxDelete.size > 0
|
|
&& !(await emailHooks.onBeforeMailboxDelete.intercept({ mailboxId, name: deletedName }))) {
|
|
return false;
|
|
}
|
|
// Only pass the options when set: the plain call keeps the two-argument
|
|
// shape other client implementations (demo, plugins) expect.
|
|
if (options?.removeEmails) {
|
|
await target.client.deleteMailbox(target.mailboxId, target.accountId, options);
|
|
} else {
|
|
await target.client.deleteMailbox(target.mailboxId, target.accountId);
|
|
}
|
|
void emailHooks.onMailboxDelete.emit({ mailboxId, name: deletedName });
|
|
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;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
setMailboxRole: async (client, mailboxId, role) => {
|
|
try {
|
|
const target = resolveMailboxMutationContext(client, mailboxId);
|
|
// If assigning a role, first clear that role from every other mailbox in
|
|
// the SAME owning account. Never clear the active user's role while
|
|
// changing a shared/group mailbox (or vice versa).
|
|
if (role) {
|
|
const existingMailboxes = resolveActionMailboxes().filter((mb) =>
|
|
mb.role === role
|
|
&& mb.id !== mailboxId
|
|
&& (target.ownerAccountId ? mb.accountId === target.ownerAccountId : !mb.isShared)
|
|
);
|
|
for (const existing of existingMailboxes) {
|
|
const existingTarget = resolveMailboxMutationContext(client, existing.id);
|
|
await existingTarget.client.updateMailbox(existingTarget.mailboxId, { role: null }, existingTarget.accountId);
|
|
}
|
|
}
|
|
await target.client.updateMailbox(target.mailboxId, { role }, target.accountId);
|
|
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;
|
|
}
|
|
},
|
|
|
|
reorderMailboxes: async (client, orderedIds) => {
|
|
// Assign a 1-based sortOrder to the given sibling group in its new order.
|
|
// sortOrder is the primary sort key (see buildMailboxTree), so this pins
|
|
// the folders' arrangement in the sidebar and settings.
|
|
const updates = orderedIds.map((id, idx) => ({ id, sortOrder: idx + 1 }));
|
|
const applyLocal = (list: Mailbox[]) =>
|
|
list.map(mb => {
|
|
const u = updates.find(x => x.id === mb.id);
|
|
return u ? { ...mb, sortOrder: u.sortOrder } : mb;
|
|
});
|
|
// Optimistic local update so the reorder is reflected immediately.
|
|
const viewingId = get().viewingAccountId;
|
|
if (viewingId) {
|
|
set((state) => ({
|
|
accountMailboxes: {
|
|
...state.accountMailboxes,
|
|
[viewingId]: applyLocal(state.accountMailboxes[viewingId] ?? []),
|
|
},
|
|
}));
|
|
} else {
|
|
set({ mailboxes: applyLocal(get().mailboxes) });
|
|
}
|
|
try {
|
|
for (const u of updates) {
|
|
const target = resolveMailboxMutationContext(client, u.id);
|
|
await target.client.updateMailbox(target.mailboxId, { sortOrder: u.sortOrder }, target.accountId);
|
|
}
|
|
} catch (error) {
|
|
set({ error: error instanceof Error ? error.message : 'Failed to reorder folders' });
|
|
// Re-sync from server so local state doesn't drift from persisted order.
|
|
if (viewingId) {
|
|
await refreshMailboxesForViewingAccount(client);
|
|
} else {
|
|
await get().fetchMailboxes(client);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
moveMailbox: async (client, mailboxId, newParentId, orderedSiblingIds) => {
|
|
const target = resolveMailboxMutationContext(client, mailboxId);
|
|
// The new parent may itself be a namespaced shared id; the server needs
|
|
// the owner's bare id. Cross-account moves are not supported by JMAP.
|
|
const parentTarget = newParentId ? resolveMailboxMutationContext(client, newParentId) : null;
|
|
const sortUpdates = (orderedSiblingIds ?? []).map((id, idx) => ({ id, sortOrder: idx + 1 }));
|
|
const movedSort = sortUpdates.find(u => u.id === mailboxId);
|
|
|
|
// Optimistic local update so the folder appears under its new parent
|
|
// immediately; the fetch below re-syncs with the server's view.
|
|
const applyLocal = (list: Mailbox[]) =>
|
|
list.map(mb => {
|
|
const patch: Partial<Mailbox> = {};
|
|
if (mb.id === mailboxId) patch.parentId = newParentId ?? undefined;
|
|
const u = sortUpdates.find(x => x.id === mb.id);
|
|
if (u) patch.sortOrder = u.sortOrder;
|
|
return Object.keys(patch).length > 0 ? { ...mb, ...patch } : mb;
|
|
});
|
|
const viewingId = get().viewingAccountId;
|
|
if (viewingId) {
|
|
set((state) => ({
|
|
accountMailboxes: {
|
|
...state.accountMailboxes,
|
|
[viewingId]: applyLocal(state.accountMailboxes[viewingId] ?? []),
|
|
},
|
|
}));
|
|
} else {
|
|
set({ mailboxes: applyLocal(get().mailboxes) });
|
|
}
|
|
|
|
try {
|
|
await target.client.updateMailbox(
|
|
target.mailboxId,
|
|
{ parentId: parentTarget ? parentTarget.mailboxId : null, ...(movedSort ? { sortOrder: movedSort.sortOrder } : {}) },
|
|
target.accountId,
|
|
);
|
|
for (const u of sortUpdates) {
|
|
if (u.id === mailboxId) continue;
|
|
const siblingTarget = resolveMailboxMutationContext(client, u.id);
|
|
await siblingTarget.client.updateMailbox(siblingTarget.mailboxId, { sortOrder: u.sortOrder }, siblingTarget.accountId);
|
|
}
|
|
} catch (error) {
|
|
set({ error: error instanceof Error ? error.message : 'Failed to move folder' });
|
|
// Re-sync from server so local state doesn't drift from the real hierarchy.
|
|
if (viewingId) {
|
|
await refreshMailboxesForViewingAccount(client);
|
|
} else {
|
|
await get().fetchMailboxes(client);
|
|
}
|
|
throw error;
|
|
}
|
|
// Reparenting changes the hierarchy, so always confirm against the server
|
|
// rather than trusting the optimistic patch (GitHub #855).
|
|
if (viewingId) {
|
|
await refreshMailboxesForViewingAccount(client);
|
|
} else {
|
|
await get().fetchMailboxes(client);
|
|
}
|
|
},
|
|
|
|
moveMailboxes: async (client, mailboxIds, newParentId) => {
|
|
// One Mailbox/set per owning account instead of one request per folder,
|
|
// so moving a hundred folders under a new parent stays quick (#1173).
|
|
const parentTarget = newParentId ? resolveMailboxMutationContext(client, newParentId) : null;
|
|
const groups: Array<{
|
|
client: IJMAPClient;
|
|
accountId: string | undefined;
|
|
updates: Record<string, { parentId: string | null }>;
|
|
storeIds: Record<string, string>;
|
|
}> = [];
|
|
for (const id of mailboxIds) {
|
|
const target = resolveMailboxMutationContext(client, id);
|
|
let group = groups.find(g => g.client === target.client && g.accountId === target.accountId);
|
|
if (!group) {
|
|
group = { client: target.client, accountId: target.accountId, updates: {}, storeIds: {} };
|
|
groups.push(group);
|
|
}
|
|
group.updates[target.mailboxId] = { parentId: parentTarget ? parentTarget.mailboxId : null };
|
|
group.storeIds[target.mailboxId] = id;
|
|
}
|
|
|
|
const moving = new Set(mailboxIds);
|
|
const applyLocal = (list: Mailbox[]) =>
|
|
list.map(mb => (moving.has(mb.id) ? { ...mb, parentId: newParentId ?? undefined } : mb));
|
|
const viewingId = get().viewingAccountId;
|
|
if (viewingId) {
|
|
set((state) => ({
|
|
accountMailboxes: {
|
|
...state.accountMailboxes,
|
|
[viewingId]: applyLocal(state.accountMailboxes[viewingId] ?? []),
|
|
},
|
|
}));
|
|
} else {
|
|
set({ mailboxes: applyLocal(get().mailboxes) });
|
|
}
|
|
|
|
const failed: string[] = [];
|
|
try {
|
|
for (const group of groups) {
|
|
const refused = await group.client.updateMailboxes(group.updates, group.accountId);
|
|
for (const bareId of Object.keys(refused)) {
|
|
failed.push(group.storeIds[bareId] ?? bareId);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
set({ error: error instanceof Error ? error.message : 'Failed to move folders' });
|
|
throw error;
|
|
} finally {
|
|
// Refused folders kept their old parent, so the optimistic patch is
|
|
// wrong for them; re-sync in every case.
|
|
if (viewingId) {
|
|
await refreshMailboxesForViewingAccount(client);
|
|
} else {
|
|
await get().fetchMailboxes(client);
|
|
}
|
|
}
|
|
return failed;
|
|
},
|
|
|
|
emptyMailbox: async (client, mailboxId): Promise<boolean> => {
|
|
const emptied = resolveActionMailboxes().find(mb => mb.id === mailboxId);
|
|
if (emailHooks.onBeforeMailboxEmpty.size > 0
|
|
&& !(await emailHooks.onBeforeMailboxEmpty.intercept({ mailboxId, role: emptied?.role ?? null }))) {
|
|
return false;
|
|
}
|
|
try {
|
|
set({ isLoading: true, error: null });
|
|
const mailboxes = resolveActionMailboxes();
|
|
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
|
|
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
|
const jmapMailboxId = mailbox?.originalId || mailboxId;
|
|
|
|
let trashMailbox: Mailbox | undefined;
|
|
let markAsRead = false;
|
|
if (mailbox && emptyFolderMovesToTrash(mailbox)) {
|
|
trashMailbox = findTrashMailbox(mailboxes, { accountId });
|
|
// The user asked to move to trash, not permanently delete.
|
|
if (!trashMailbox) throw new Error('Trash mailbox not found - cannot move emails to trash');
|
|
markAsRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
|
await resolveActionClient(client).moveMailboxContents(
|
|
jmapMailboxId,
|
|
trashMailbox.originalId || trashMailbox.id,
|
|
accountId,
|
|
markAsRead,
|
|
);
|
|
} else {
|
|
await resolveActionClient(client).emptyMailbox(jmapMailboxId, accountId);
|
|
}
|
|
|
|
// Clear emails from local state if we're viewing this mailbox
|
|
const currentMailbox = get().selectedMailbox;
|
|
if (currentMailbox === mailboxId) {
|
|
set({ emails: [], selectedEmail: null });
|
|
}
|
|
|
|
const updateCounters = (mb: Mailbox): Mailbox => {
|
|
if (mb.id === mailboxId) {
|
|
return { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 };
|
|
}
|
|
if (mailbox && mb.id === trashMailbox?.id) {
|
|
return {
|
|
...mb,
|
|
totalEmails: mb.totalEmails + mailbox.totalEmails,
|
|
unreadEmails: mb.unreadEmails + (markAsRead ? 0 : mailbox.unreadEmails),
|
|
totalThreads: mb.totalThreads + mailbox.totalThreads,
|
|
unreadThreads: mb.unreadThreads + (markAsRead ? 0 : mailbox.unreadThreads),
|
|
};
|
|
}
|
|
return mb;
|
|
};
|
|
|
|
const viewingId = get().viewingAccountId;
|
|
if (viewingId) {
|
|
set((state) => ({
|
|
accountMailboxes: {
|
|
...state.accountMailboxes,
|
|
[viewingId]: (state.accountMailboxes[viewingId] ?? []).map(updateCounters),
|
|
},
|
|
}));
|
|
} else {
|
|
set({ mailboxes: get().mailboxes.map(updateCounters) });
|
|
}
|
|
set({ isLoading: false });
|
|
void emailHooks.onMailboxEmpty.emit({ mailboxId, role: mailbox?.role ?? null, count: mailbox?.totalEmails ?? 0 });
|
|
} catch (error) {
|
|
set({
|
|
error: error instanceof Error ? error.message : 'Failed to empty folder',
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
markAllMailboxesAsRead: async (client) => {
|
|
return markReadAskingPlugins(client, {}, undefined, (actionClient) => actionClient.markAllAsRead());
|
|
},
|
|
|
|
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 markReadAskingPlugins(
|
|
resolveActionClient(client), { inMailbox: jmapMailboxId }, accountId,
|
|
(actionClient) => actionClient.markMailboxAsRead(jmapMailboxId, accountId),
|
|
);
|
|
if (count === null) return null;
|
|
|
|
// 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
|
|
),
|
|
}));
|
|
|
|
// Tag counts are refetched here rather than adjusted with a local delta
|
|
// (as markAsRead/batchMarkAsRead do). This is a server-side bulk operation
|
|
// over the *whole* mailbox, so it also marks emails that were never loaded
|
|
// into `state.emails` - a local delta would only see the loaded page and
|
|
// would leave the tag counts drifting high. Fire-and-forget: the folder
|
|
// counters above already update instantly.
|
|
void get().fetchTagCounts(client);
|
|
|
|
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) => {
|
|
const token = get().viewToken + 1;
|
|
set({
|
|
isLoading: true,
|
|
error: null,
|
|
isUnifiedView: true,
|
|
unifiedRole: role,
|
|
crossView: null,
|
|
selectedKeyword: null,
|
|
retainedInViewIds: new Set(),
|
|
listHold: null,
|
|
viewToken: token,
|
|
});
|
|
try {
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
const order = getMessageListOrderFor(role);
|
|
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0, order);
|
|
// The user may have navigated to a different mailbox/keyword/unified view
|
|
// while this was in flight — don't let a stale response overwrite it.
|
|
if (get().viewToken !== token) return;
|
|
set({
|
|
emails: result.emails,
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
listOrder: order,
|
|
listKeepsServerTextOrder: false,
|
|
isLoading: false,
|
|
unifiedErrors: result.errors,
|
|
});
|
|
} catch (error) {
|
|
if (get().viewToken !== token) return;
|
|
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 = positionsByAccount(emails);
|
|
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position, getMessageListOrderFor(unifiedRole));
|
|
|
|
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 {
|
|
// Store the scope and project live counters over it. The badges then track
|
|
// the live mailbox lists via the store subscription (below), so subsequent
|
|
// optimistic mutations update them without another build/fetch.
|
|
set((state) => {
|
|
const next = { ...state, unifiedScope: accounts };
|
|
return { unifiedScope: accounts, ...projectUnifiedCounts(next) };
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to refresh unified counts:', error);
|
|
}
|
|
},
|
|
|
|
fetchCrossView: async (accounts, view) => {
|
|
const token = get().viewToken + 1;
|
|
set({
|
|
isLoading: true,
|
|
error: null,
|
|
isUnifiedView: true,
|
|
unifiedRole: null,
|
|
crossView: view,
|
|
selectedKeyword: null,
|
|
retainedInViewIds: new Set(),
|
|
listHold: null,
|
|
viewToken: token,
|
|
});
|
|
try {
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
const result = await fetchCrossViewEmails(accounts, view, emailsPerPage, 0);
|
|
// The user may have navigated to a different mailbox/keyword/unified view
|
|
// while this was in flight — don't let a stale response overwrite it.
|
|
if (get().viewToken !== token) return;
|
|
set({
|
|
emails: result.emails,
|
|
hasMoreEmails: result.hasMore,
|
|
totalEmails: result.total,
|
|
isLoading: false,
|
|
unifiedErrors: result.errors,
|
|
});
|
|
} catch (error) {
|
|
if (get().viewToken !== token) return;
|
|
console.error('Failed to fetch cross-account view:', error);
|
|
set({
|
|
error: error instanceof Error ? error.message : "Failed to fetch cross-account view",
|
|
isLoading: false,
|
|
emails: [],
|
|
hasMoreEmails: false,
|
|
totalEmails: 0,
|
|
});
|
|
}
|
|
},
|
|
|
|
refreshCrossCounts: (accounts) => {
|
|
try {
|
|
set((state) => {
|
|
const next = { ...state, unifiedScope: accounts };
|
|
return { unifiedScope: accounts, ...projectUnifiedCounts(next) };
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to refresh cross-account counts:', error);
|
|
}
|
|
},
|
|
|
|
exitUnifiedView: () => {
|
|
set(state => ({
|
|
isUnifiedView: false,
|
|
unifiedRole: null,
|
|
crossView: null,
|
|
unifiedErrors: new Map(),
|
|
retainedInViewIds: new Set(),
|
|
listHold: null,
|
|
viewToken: state.viewToken + 1,
|
|
}));
|
|
},
|
|
|
|
setScheduledView: (isScheduledView, accountScope = null) => set(state => {
|
|
const leavingScheduled = !isScheduledView && state.selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID;
|
|
return {
|
|
isScheduledView,
|
|
scheduledAccountScope: isScheduledView ? accountScope : null,
|
|
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
|
|
selectedEmail: leavingScheduled ? null : state.selectedEmail,
|
|
selectedEmailKeys: leavingScheduled ? new Set<string>() : state.selectedEmailKeys,
|
|
// Search is unavailable in the scheduled view (the input is disabled there).
|
|
// Reset any active search when entering it so a stale query can't linger or
|
|
// re-run when the user leaves again.
|
|
searchQuery: isScheduledView ? "" : state.searchQuery,
|
|
searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters,
|
|
searchMailboxId: isScheduledView ? "" : state.searchMailboxId,
|
|
searchScopeFollowsFolder: isScheduledView ? false : state.searchScopeFollowsFolder,
|
|
};
|
|
}),
|
|
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);
|
|
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
const scheduledEmailIds = new Set(result.emails.map(email => email.id));
|
|
const scheduledSubmissionByEmailId = scheduledSubmissionMap(client, result.emails);
|
|
const pendingUndoSend = get().pendingUndoSend;
|
|
set({
|
|
scheduledEmails: result.emails,
|
|
scheduledEmailIds,
|
|
scheduledSubmissionByEmailId,
|
|
scheduledTotal: result.total,
|
|
scheduledTotalByAccount: result.totalByAccount ?? {},
|
|
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);
|
|
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
|
|
const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => isSameScheduledSend(existing, email)))];
|
|
const pendingUndoSend = get().pendingUndoSend;
|
|
set({
|
|
scheduledEmails: merged,
|
|
scheduledEmailIds: new Set(merged.map(email => email.id)),
|
|
scheduledSubmissionByEmailId: scheduledSubmissionMap(client, merged),
|
|
scheduledTotal: result.total,
|
|
scheduledTotalByAccount: result.totalByAccount ?? {},
|
|
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) => {
|
|
// This runs at startup, after scheduled sends and undo, and whenever
|
|
// EmailSubmission state changes - the moments submission records pile up
|
|
// or turn prunable. The client throttles the prune per account; the list
|
|
// doesn't depend on it, so it isn't awaited.
|
|
void client.pruneEmailSubmissions();
|
|
try {
|
|
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
|
const allEmails: ScheduledEmail[] = [];
|
|
let position = 0;
|
|
let hasMore = true;
|
|
let total = 0;
|
|
let totalByAccount: Record<string, number> = {};
|
|
while (hasMore) {
|
|
const page = await client.getScheduledEmails(emailsPerPage, position);
|
|
allEmails.push(...page.emails.filter(email => !allEmails.some(existing => isSameScheduledSend(existing, email))));
|
|
total = page.total;
|
|
totalByAccount = page.totalByAccount ?? totalByAccount;
|
|
hasMore = page.hasMore && page.nextPosition > position;
|
|
position = page.nextPosition;
|
|
}
|
|
const pendingUndoSend = get().pendingUndoSend;
|
|
const scheduledSubmissionByEmailId = scheduledSubmissionMap(client, allEmails);
|
|
set({
|
|
scheduledEmails: get().isScheduledView ? allEmails : get().scheduledEmails,
|
|
scheduledEmailIds: new Set(allEmails.map(email => email.id)),
|
|
scheduledSubmissionByEmailId,
|
|
// Re-annotate the visible list: this refresh runs concurrently with
|
|
// the initial fetchEmails, so rows fetched before the metadata landed
|
|
// still get their scheduled badges.
|
|
emails: annotateScheduledEmails(get().emails, scheduledSubmissionByEmailId),
|
|
scheduledTotal: total,
|
|
scheduledTotalByAccount: totalByAccount,
|
|
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) => {
|
|
const owner = get().scheduledEmails.find(email => email.emailSubmissionId === submissionId);
|
|
await client.cancelEmailSubmission(submissionId, owner?.scheduledAccountId);
|
|
if (emailId) {
|
|
// The message lives beside its submission, not always in the own account.
|
|
await client.deleteEmail(emailId, owner?.scheduledAccountId);
|
|
set(state => ({
|
|
selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail,
|
|
selectedEmailKeys: dropFromSelection(state, e => e.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;
|
|
// The message lives beside its submission (see getScheduledEmails).
|
|
const accountId = email.scheduledAccountId;
|
|
await client.cancelEmailSubmission(submissionId, accountId);
|
|
if (get().pendingUndoSend?.submissionId === submissionId) {
|
|
set({ pendingUndoSend: null });
|
|
}
|
|
// Nothing goes out any more; a failure from here on is not a failed cancel.
|
|
try {
|
|
if (email.isSmimeScheduled) {
|
|
await client.deleteEmail(email.id, accountId);
|
|
set(state => ({
|
|
selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail,
|
|
selectedEmailKeys: dropFromSelection(state, e => e.id === email.id),
|
|
}));
|
|
} else {
|
|
await restoreSentToDraft(client, get().mailboxes, email.id, accountId);
|
|
}
|
|
} catch (error) {
|
|
void get().fetchScheduledEmails(client);
|
|
throw new SendCancelledCleanupError(email.isSmimeScheduled ? 'remove' : 'restore', error);
|
|
}
|
|
await get().fetchScheduledEmails(client);
|
|
if (email.isSmimeScheduled) return null;
|
|
// Back in Drafts either way; the caller opens it when it can.
|
|
return client.getEmail(email.id, accountId).catch((error) => {
|
|
console.error('Failed to load the restored draft:', error);
|
|
return null;
|
|
});
|
|
},
|
|
|
|
rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil, accountId) => {
|
|
let result: SendEmailResult | undefined;
|
|
const inAccount = (candidate: string | undefined) => !accountId || !candidate || candidate === accountId;
|
|
try {
|
|
const owner = get().scheduledEmails.find(email => email.emailSubmissionId === submissionId && inAccount(email.scheduledAccountId));
|
|
result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil, accountId ?? owner?.scheduledAccountId);
|
|
const pendingUndoSend = get().pendingUndoSend;
|
|
if (pendingUndoSend?.submissionId === submissionId && inAccount(pendingUndoSend.submissionAccountId)) {
|
|
set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } });
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
// The original was put back under a new id; Undo has to cancel that one.
|
|
if (error instanceof RescheduleRestoredError) {
|
|
const pendingUndoSend = get().pendingUndoSend;
|
|
if (pendingUndoSend?.submissionId === submissionId && inAccount(pendingUndoSend.submissionAccountId)) {
|
|
set({ pendingUndoSend: { ...pendingUndoSend, submissionId: error.restoredSubmissionId, sendAt: error.restoredSendAt || pendingUndoSend.sendAt } });
|
|
}
|
|
}
|
|
throw error;
|
|
} finally {
|
|
await get().fetchScheduledEmails(client);
|
|
if (result && get().selectedEmail?.id === emailId) {
|
|
const refreshed = get().scheduledEmails.find(email => email.id === emailId && inAccount(email.scheduledAccountId));
|
|
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) => {
|
|
// A submission refers to an Email of its own account, so the message is
|
|
// where the submission is: in the group or shared account when it was
|
|
// sent from that account's identity.
|
|
const accountId = pending.submissionAccountId;
|
|
await client.cancelEmailSubmission(pending.submissionId, accountId);
|
|
// Nothing goes out any more. A failure from here on must not read as a
|
|
// failed undo, which would tell the user the mail may still leave.
|
|
set({ pendingUndoSend: null });
|
|
try {
|
|
if (pending.emailId && pending.isSmime) {
|
|
await client.deleteEmail(pending.emailId, accountId);
|
|
set(state => ({
|
|
selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail,
|
|
selectedEmailKeys: dropFromSelection(state, e => e.id === pending.emailId),
|
|
}));
|
|
} else if (pending.emailId) {
|
|
await restoreSentToDraft(client, get().mailboxes, pending.emailId, accountId);
|
|
}
|
|
} catch (error) {
|
|
void get().refreshScheduledMetadata(client);
|
|
throw new SendCancelledCleanupError(pending.isSmime ? 'remove' : 'restore', error);
|
|
}
|
|
await get().refreshScheduledMetadata(client);
|
|
if (!pending.emailId || pending.isSmime) return null;
|
|
// Back in Drafts either way; the caller opens it when it can.
|
|
return client.getEmail(pending.emailId, accountId).catch((error) => {
|
|
console.error('Failed to load the restored draft:', error);
|
|
return 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,
|
|
});
|
|
},
|
|
}));
|
|
|
|
// Plugin hooks that follow a piece of store state rather than one action, so
|
|
// every path that changes it (clicks, keyboard, a batch action clearing the
|
|
// selection, an account switch) fires them once.
|
|
useEmailStore.subscribe((state, prev) => {
|
|
if (state.selectedEmailKeys !== prev.selectedEmailKeys && emailHooks.onEmailSelectionChange.size > 0) {
|
|
const changed = state.selectedEmailKeys.size !== prev.selectedEmailKeys.size
|
|
|| [...state.selectedEmailKeys].some(key => !prev.selectedEmailKeys.has(key));
|
|
if (changed) void emailHooks.onEmailSelectionChange.emit({ emailIds: idsFromSelectionKeys(state.selectedEmailKeys) });
|
|
}
|
|
if (state.selectedMailbox !== prev.selectedMailbox && state.selectedMailbox && emailHooks.onMailboxChange.size > 0) {
|
|
const mailbox = mailboxesInView(state).find(mb => mb.id === state.selectedMailbox);
|
|
void emailHooks.onMailboxChange.emit({
|
|
mailboxId: state.selectedMailbox,
|
|
role: mailbox?.role ?? (state.isUnifiedView ? state.unifiedRole : null) ?? null,
|
|
previousMailboxId: prev.selectedMailbox || null,
|
|
});
|
|
}
|
|
if (state.quota && state.quota !== prev.quota
|
|
&& (state.quota.used !== prev.quota?.used || state.quota.total !== prev.quota?.total)) {
|
|
void emailHooks.onQuotaChange.emit(quotaToView(state.quota));
|
|
}
|
|
if (state.isPushConnected !== prev.isPushConnected) {
|
|
void emailHooks.onPushConnectionChange.emit({ connected: state.isPushConnected });
|
|
}
|
|
});
|
|
|
|
// Keep the unified-section badges in lockstep with the live per-account mailbox
|
|
// lists. Whenever `mailboxes`, `accountMailboxes`, or the unified scope change -
|
|
// i.e. after any optimistic delete/move/markRead patch or a push-driven mailbox
|
|
// refresh - re-project the badges from that single source of truth. The guard
|
|
// short-circuits on every unrelated state change (emails, loading flags, …) by
|
|
// reference equality, and our own counter writes don't re-enter the projection
|
|
// because they leave the three watched lists untouched (no loop). (#281)
|
|
useEmailStore.subscribe((state, prev) => {
|
|
if (
|
|
state.mailboxes === prev.mailboxes &&
|
|
state.accountMailboxes === prev.accountMailboxes &&
|
|
state.unifiedScope === prev.unifiedScope
|
|
) {
|
|
return;
|
|
}
|
|
if (state.unifiedScope.length === 0) return;
|
|
const projected = projectUnifiedCounts(state);
|
|
const sameCross = projected.crossUnreadCount === state.crossUnreadCount;
|
|
const sameUnified =
|
|
projected.unifiedCounts.length === state.unifiedCounts.length &&
|
|
projected.unifiedCounts.every((c, i) => {
|
|
const cur = state.unifiedCounts[i];
|
|
return cur && cur.role === c.role && cur.unreadEmails === c.unreadEmails && cur.totalEmails === c.totalEmails;
|
|
});
|
|
if (sameCross && sameUnified) return;
|
|
useEmailStore.setState(projected);
|
|
});
|
|
|
|
// Opening a message holds its list row (see listHold), however it was opened:
|
|
// a click, next/previous, or the pick after a delete. This runs before the
|
|
// message is marked read, so the hold records it unread.
|
|
useEmailStore.subscribe((state, prev) => {
|
|
const email = state.selectedEmail;
|
|
if (!email || email.id === prev.selectedEmail?.id) return;
|
|
state.holdListRow(listRowKey(email, state.isScheduledView));
|
|
});
|
|
|
|
// A folder's remembered list follows what happens to it on screen - a message
|
|
// read, archived or deleted here is shown that way when the folder is opened
|
|
// again, rather than as it was when the folder was loaded.
|
|
useEmailStore.subscribe((state, prev) => {
|
|
if (state.mailboxes.length === 0 && state.emails.length === 0) {
|
|
shownFolderList = null;
|
|
forgetFolderLists();
|
|
return;
|
|
}
|
|
if (state.emails === prev.emails || !shownFolderList) return;
|
|
if (
|
|
state.selectedMailbox !== shownFolderList.mailboxId ||
|
|
state.searchQuery ||
|
|
!isFilterEmpty(state.searchFilters) ||
|
|
state.selectedKeyword ||
|
|
state.isUnifiedView ||
|
|
state.isScheduledView
|
|
) {
|
|
shownFolderList = null;
|
|
return;
|
|
}
|
|
rememberFolderList(shownFolderList.key, folderListState(state), shownFolderList.pageSize);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Boot snapshot: persist the mailbox list and the first page of the current
|
|
// list so a returning visit paints rows the moment auth resolves, instead of
|
|
// waiting on the Mailbox/get + Email/query round trips. The live fetch still
|
|
// runs and replaces the snapshot in the background (mail-app passes
|
|
// { background: true } when the list is already populated).
|
|
// ---------------------------------------------------------------------------
|
|
const EMAIL_SNAPSHOT_KEY = 'email-snapshot';
|
|
// v2: unified / cross-account lists are no longer snapshotted (their rows carry
|
|
// per-account source stamps that a fresh boot ignores, so a colliding JMAP id
|
|
// opened the wrong account's mail - #847). Older snapshots are discarded.
|
|
// v3: the snapshot names its account and is only restored for that account.
|
|
const EMAIL_SNAPSHOT_VERSION = 3;
|
|
const EMAIL_SNAPSHOT_MAX_EMAILS = 50;
|
|
|
|
function isEmailListSync(value: unknown, mailboxId: string): value is EmailListSync {
|
|
const sync = value as EmailListSync | null;
|
|
return !!sync && typeof sync.state === 'string' && typeof sync.accountId === 'string' && sync.mailboxId === mailboxId;
|
|
}
|
|
|
|
/** The signed-in session persisted by auth-store, read without importing it. */
|
|
function persistedAuthState(): { isAuthenticated?: unknown; activeAccountId?: unknown } | null {
|
|
try {
|
|
const raw = window.localStorage.getItem('auth-storage');
|
|
return raw ? JSON.parse(raw)?.state ?? null : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
// Restore at module load, before the first render reads the store - and only
|
|
// when the last session ended authenticated, so a logged-out visitor never
|
|
// sees cached mail, and for the account the snapshot was taken from.
|
|
try {
|
|
const auth = persistedAuthState();
|
|
const authed = auth?.isAuthenticated === true;
|
|
const raw = authed ? window.localStorage.getItem(EMAIL_SNAPSHOT_KEY) : null;
|
|
if (raw) {
|
|
const snap = JSON.parse(raw);
|
|
if (
|
|
snap?.v === EMAIL_SNAPSHOT_VERSION &&
|
|
typeof snap.account === 'string' &&
|
|
snap.account === auth?.activeAccountId &&
|
|
Array.isArray(snap.mailboxes) &&
|
|
snap.mailboxes.length > 0
|
|
) {
|
|
const snapMailbox = typeof snap.selectedMailbox === 'string' ? snap.selectedMailbox : '';
|
|
// The snapshot holds the last plain folder that was open, which may be
|
|
// Trash from a session that ended in a unified view. Only a reload of
|
|
// a folder link continues there; opening the app starts in the
|
|
// account's own inbox, and rows cached for another folder are dropped.
|
|
const ownInbox = (snap.mailboxes as Mailbox[]).find(m => m.role === 'inbox' && !m.isShared);
|
|
const selectedMailbox = pathNamesMailFolder(window.location.pathname) || !ownInbox
|
|
? snapMailbox
|
|
: ownInbox.id;
|
|
useEmailStore.setState({
|
|
mailboxes: snap.mailboxes,
|
|
selectedMailbox,
|
|
searchMailboxId: defaultSearchScopeFor(snap.mailboxes, selectedMailbox),
|
|
...(selectedMailbox === snapMailbox && Array.isArray(snap.emails) && snap.emails.length > 0
|
|
? {
|
|
emails: snap.emails,
|
|
totalEmails: typeof snap.totalEmails === 'number' ? snap.totalEmails : snap.emails.length,
|
|
hasMoreEmails: !!snap.hasMoreEmails,
|
|
// The order the rows were fetched in; the list is drawn sorted
|
|
// by it, so without it an "unread first" page shows by date
|
|
// until the live fetch lands and re-sorts it.
|
|
...(Array.isArray(snap.listOrder) ? { listOrder: sanitizeSortLevels(snap.listOrder) } : {}),
|
|
...(isEmailListSync(snap.emailListSync, snapMailbox) ? { emailListSync: snap.emailListSync } : {}),
|
|
}
|
|
: {}),
|
|
});
|
|
}
|
|
}
|
|
} catch { /* corrupt snapshot - start empty */ }
|
|
|
|
let snapshotTimer: ReturnType<typeof setTimeout> | null = null;
|
|
useEmailStore.subscribe((state, prev) => {
|
|
if (
|
|
state.emails === prev.emails &&
|
|
state.mailboxes === prev.mailboxes &&
|
|
state.selectedMailbox === prev.selectedMailbox
|
|
) {
|
|
return;
|
|
}
|
|
// A reset (logout / store wipe) must drop the snapshot immediately - mail
|
|
// data may not outlive the session in localStorage.
|
|
if (state.mailboxes.length === 0 && state.emails.length === 0) {
|
|
if (snapshotTimer) { clearTimeout(snapshotTimer); snapshotTimer = null; }
|
|
try { window.localStorage.removeItem(EMAIL_SNAPSHOT_KEY); } catch { /* ignore */ }
|
|
return;
|
|
}
|
|
if (snapshotTimer) clearTimeout(snapshotTimer);
|
|
// Debounced trailing write: the store updates far too often to serialize
|
|
// the list on every set().
|
|
snapshotTimer = setTimeout(() => {
|
|
snapshotTimer = null;
|
|
const s = useEmailStore.getState();
|
|
if (s.mailboxes.length === 0) return;
|
|
// Don't snapshot search results, keyword filters, the scheduled view, a
|
|
// secondary account's view or a unified/cross-account list - the previous
|
|
// plain listing stays in place for the next boot. A unified list mixes
|
|
// accounts whose email ids collide; restored without its view flags it
|
|
// would be read as the active account's mail. (#847)
|
|
if (s.searchQuery || s.selectedKeyword || s.isScheduledView || s.viewingAccountId || s.isUnifiedView) return;
|
|
const account = persistedAuthState()?.activeAccountId;
|
|
if (typeof account !== 'string' || !account) return;
|
|
try {
|
|
window.localStorage.setItem(EMAIL_SNAPSHOT_KEY, JSON.stringify({
|
|
v: EMAIL_SNAPSHOT_VERSION,
|
|
account,
|
|
mailboxes: s.mailboxes,
|
|
selectedMailbox: s.selectedMailbox,
|
|
emails: s.emails.slice(0, EMAIL_SNAPSHOT_MAX_EMAILS),
|
|
totalEmails: s.totalEmails,
|
|
hasMoreEmails: s.hasMoreEmails || s.emails.length > EMAIL_SNAPSHOT_MAX_EMAILS,
|
|
listOrder: s.listOrder,
|
|
emailListSync: s.emailListSync,
|
|
}));
|
|
} catch { /* storage quota - skip */ }
|
|
}, 1000);
|
|
});
|
|
}
|