feat: cross-account "All accounts" views + full group/shared-account support
Add cross-account aggregate mail views and make group/shared (delegated) accounts first-class in every aggregate view. (The unified mailbox, the "All Mail" view, and "include group inboxes" already exist on main; this branch adds the cross-account views and the shared-account correctness work.) New views (admin-gated + per-user toggle, nested under Unified Mailbox): - Cross-account "All accounts": All unread / All starred / All mail across every connected account, including shared/group folders. Each list labels the source folder of every message. Source reference on aggregated emails (the core of the shared-account work): - Replace the overloaded `accountId` with two explicit, always-set fields: `sourceClientAccountId` (the login the mail is reachable through) and `sourceAccountId` (the owning JMAP account). `accountId` stays display-only. - Resolution is branch-free everywhere: pick the client by sourceClientAccountId, pass sourceAccountId as the JMAP accountId (no-op for personal), read the owner's mailbox list cached by JMAP id. No capability scan. Shared/group-account correctness across all aggregate views: - Route open (click + auto-fetch), thread/conversation open + reply-refresh, mark read, star, move, delete (account-scoped trash), archive (owner-routed createMailbox / fetchAccountMailboxes), and spam + undo via the source ref. - Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails / createMailbox where missing. - Fix local unread/total counter math for shared folders via emailInMailbox() (matches namespaced shared ids and bare own ids). - Keep the unified/cross virtual selection on background mailbox refresh (no jump back to inbox after deleting in All Drafts/Junk). Junk UX: - In "All Junk" the spam action becomes "not spam" in the viewer, context menu, and list hover icons; undo routes shared mail back to its own inbox. Admin: - Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching per-user toggle (allMailViewEnabled clarified too). i18n / docs / tests: - locales (19): cross-view labels + descriptions and hover not_spam, translated in all shipped languages. - FEATURES.md + README.md document the new views and group-account support. - Tests for shared-account routing (single + batch + undoSpam), decoration, and unified-selection preservation.
This commit is contained in:
+366
-111
@@ -1,13 +1,13 @@
|
||||
import { create } from "zustand";
|
||||
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
|
||||
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, ALL_MAIL_MAILBOX_ID, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
|
||||
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
||||
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||||
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, getCrossUnreadTotal, resolveSourceFolderName, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
|
||||
@@ -77,8 +77,14 @@ interface EmailStore {
|
||||
// Unified mailbox state
|
||||
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
|
||||
unifiedCounts: UnifiedMailboxCounts[];
|
||||
// Unread total across the cross-view included folders (badge for unread/all).
|
||||
crossUnreadCount: number;
|
||||
|
||||
// Scheduled send state
|
||||
scheduledEmails: ScheduledEmail[];
|
||||
@@ -181,9 +187,10 @@ interface EmailStore {
|
||||
batchArchive: (client: IJMAPClient) => Promise<void>;
|
||||
|
||||
// Spam operations
|
||||
// `sourceAccountId` (when set) is the unified-view email's owning account,
|
||||
// used to route the undo back to the right account client. (#281)
|
||||
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string; sourceAccountId?: string }>;
|
||||
// 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 }>;
|
||||
markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
|
||||
undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
|
||||
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
|
||||
@@ -217,6 +224,9 @@ interface EmailStore {
|
||||
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>;
|
||||
@@ -350,33 +360,39 @@ function buildAllMailFilter(jmapMailboxIds: string[]): Record<string, unknown> {
|
||||
* Resolves the JMAP client, mailbox list, and JMAP accountId to use for a
|
||||
* single-email action.
|
||||
*
|
||||
* In unified view each email carries the `accountId` of the account it came
|
||||
* from. The mutation must be routed to that account's own client/session, 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). The per-account client already targets the owning
|
||||
* account, so no explicit JMAP `accountId` override is needed, and its cached
|
||||
* mailbox list (populated by `buildUnifiedAccountClients`) is used to resolve
|
||||
* role-based destinations like trash/archive.
|
||||
* 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 that JMAP
|
||||
* id) 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.
|
||||
*/
|
||||
function resolveEmailActionContext(
|
||||
email: { accountId?: string },
|
||||
email: { sourceClientAccountId?: string; sourceAccountId?: string },
|
||||
passedClient: IJMAPClient,
|
||||
): { client: IJMAPClient; mailboxes: Mailbox[]; accountId: string | undefined } {
|
||||
const state = useEmailStore.getState();
|
||||
if (state.isUnifiedView && email.accountId) {
|
||||
const perAccountClient = useAuthStore.getState().getClientForAccount(email.accountId);
|
||||
if (perAccountClient) {
|
||||
return {
|
||||
client: perAccountClient,
|
||||
mailboxes: state.accountMailboxes[email.accountId] ?? state.mailboxes,
|
||||
accountId: undefined,
|
||||
};
|
||||
}
|
||||
// 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).
|
||||
if (state.isUnifiedView && email.sourceClientAccountId && email.sourceAccountId) {
|
||||
return {
|
||||
client: useAuthStore.getState().getClientForAccount(email.sourceClientAccountId) ?? resolveActionClient(passedClient),
|
||||
mailboxes: state.accountMailboxes[email.sourceAccountId] ?? state.mailboxes,
|
||||
accountId: email.sourceAccountId,
|
||||
};
|
||||
}
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
const currentMailbox = mailboxes.find((mb) => mb.id === state.selectedMailbox);
|
||||
@@ -418,8 +434,16 @@ export async function buildUnifiedAccountClients(
|
||||
const ownMailboxes = includeGroup
|
||||
? mailboxes.filter((m) => !m.isShared)
|
||||
: mailboxes;
|
||||
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, isShared: false });
|
||||
// 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 });
|
||||
fetchedMailboxes[a.id] = ownMailboxes;
|
||||
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
|
||||
// resolves uniformly for personal and shared sources alike.
|
||||
fetchedMailboxes[primaryJmapId] = ownMailboxes;
|
||||
|
||||
if (includeGroup) {
|
||||
const sharedByOwner = new Map<string, Mailbox[]>();
|
||||
@@ -436,8 +460,14 @@ export async function buildUnifiedAccountClients(
|
||||
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.
|
||||
fetchedMailboxes[ownerId] = ownerMailboxes;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -475,6 +505,23 @@ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): P
|
||||
}
|
||||
}
|
||||
|
||||
// Whether an email belongs to a given mailbox, for local counter math.
|
||||
// Shared/group-account emails carry NAMESPACED mailboxIds (`${ownerId}:${origId}`,
|
||||
// which equals the shared mailbox's `id`), while own-account emails carry bare ids
|
||||
// (equal to both `id` and `originalId`). Matching `mailbox.id` covers both; the
|
||||
// `originalId` fallback is restricted to non-shared mailboxes so a bare own-account
|
||||
// id can't collide with another account's shared folder. (#281)
|
||||
function emailInMailbox(
|
||||
email: { mailboxIds?: Record<string, boolean> },
|
||||
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];
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -538,8 +585,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Unified mailbox state
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
crossView: null,
|
||||
unifiedErrors: new Map(),
|
||||
unifiedCounts: [],
|
||||
crossUnreadCount: 0,
|
||||
|
||||
// Scheduled send state
|
||||
scheduledEmails: [],
|
||||
@@ -575,7 +624,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}),
|
||||
fetchAccountMailboxes: async (client, accountId) => {
|
||||
try {
|
||||
const mailboxes = await client.getMailboxes();
|
||||
// `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) => ({
|
||||
@@ -694,6 +755,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const currentSelectedMailbox = get().selectedMailbox;
|
||||
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|
||||
|| currentSelectedMailbox === ALL_MAIL_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));
|
||||
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
|
||||
if (!selectionValid) {
|
||||
@@ -761,6 +828,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const result = await resolveActionClient(client).advancedSearchEmails(
|
||||
buildAllMailFilter(jmapIds), undefined, emailsPerPage, 0,
|
||||
);
|
||||
const allMailMailboxes = resolveActionMailboxes();
|
||||
for (const email of result.emails) {
|
||||
email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes);
|
||||
}
|
||||
set({
|
||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||
hasMoreEmails: result.hasMore,
|
||||
@@ -814,11 +885,43 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
loadMoreEmails: async (client) => {
|
||||
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole } = get();
|
||||
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole, crossView } = get();
|
||||
|
||||
// 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 = emails.length;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const result = 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));
|
||||
set({
|
||||
emails: [...currentEmails, ...newEmails],
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoadingMore: false,
|
||||
unifiedErrors: result.errors,
|
||||
});
|
||||
} catch (error) {
|
||||
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.
|
||||
@@ -922,6 +1025,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
|
||||
}
|
||||
|
||||
if (selectedMailbox === ALL_MAIL_MAILBOX_ID) {
|
||||
const allMailMailboxes = resolveActionMailboxes();
|
||||
for (const email of result.emails) {
|
||||
email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes);
|
||||
}
|
||||
}
|
||||
|
||||
// Use fresh state when merging to avoid overwriting concurrent updates
|
||||
// (e.g. refreshCurrentMailbox running during the load)
|
||||
const currentEmails = get().emails;
|
||||
@@ -952,15 +1062,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
fetchEmailContent: async (client, emailId) => {
|
||||
try {
|
||||
// Find the selected mailbox to determine accountId (for shared folders)
|
||||
const selectedMailboxId = get().selectedMailbox;
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
|
||||
// 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 listEmail = get().emails.find(e => e.id === emailId);
|
||||
let actionClient: IJMAPClient;
|
||||
let accountId: string | undefined;
|
||||
if (listEmail) {
|
||||
({ client: actionClient, accountId } = resolveEmailActionContext(listEmail, client));
|
||||
} else {
|
||||
const mailbox = resolveActionMailboxes().find(mb => mb.id === get().selectedMailbox);
|
||||
actionClient = resolveActionClient(client);
|
||||
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
}
|
||||
|
||||
// Only pass accountId for shared mailboxes
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
|
||||
const email = await resolveActionClient(client).getEmail(emailId, accountId);
|
||||
const email = await actionClient.getEmail(emailId, accountId);
|
||||
|
||||
if (email) {
|
||||
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
|
||||
@@ -1049,8 +1166,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// In unified view it comes from the email's own folders (matching the
|
||||
// unified role), not the active account's selected mailbox.
|
||||
const currentMailbox = get().isUnifiedView
|
||||
? (mailboxes.find(mb => email.mailboxIds?.[mb.id] && mb.role === get().unifiedRole)
|
||||
?? mailboxes.find(mb => email.mailboxIds?.[mb.id]))
|
||||
? (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
|
||||
@@ -1079,7 +1196,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Update counters for source mailbox (email leaving)
|
||||
if (email.mailboxIds) {
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
if (emailInMailbox(email, mailbox)) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
@@ -1126,7 +1243,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// If the email was unread, decrement the unread counters
|
||||
if (isUnread && email.mailboxIds) {
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
if (emailInMailbox(email, mailbox)) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
@@ -1140,7 +1257,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
} else if (email.mailboxIds) {
|
||||
// If email was read, only decrement total counters
|
||||
updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (email.mailboxIds[mailbox.id]) {
|
||||
if (emailInMailbox(email, mailbox)) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
@@ -1179,7 +1296,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
if (!email) return;
|
||||
|
||||
// Check if already in the desired state
|
||||
const isCurrentlyRead = email.keywords?.$seen === true;
|
||||
const isCurrentlyRead = email.keywords?.$seen;
|
||||
if (isCurrentlyRead === read) {
|
||||
return; // Already in desired state
|
||||
}
|
||||
@@ -1205,14 +1322,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const emailInState = state.emails.find(e => e.id === emailId);
|
||||
if (!emailInState) return { processingReadStatus: newProcessingSet };
|
||||
|
||||
const wasRead = emailInState.keywords?.$seen === true;
|
||||
const wasRead = emailInState.keywords?.$seen;
|
||||
if (wasRead === read) {
|
||||
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
|
||||
}
|
||||
|
||||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
// Check if this email belongs to this mailbox
|
||||
if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
|
||||
// Check if this email belongs to this mailbox. Shared mailboxes are
|
||||
// stored under a namespaced id but the email's mailboxIds are keyed by
|
||||
// the owner-side JMAP id (originalId), so match on originalId first.
|
||||
if (emailInMailbox(emailInState, mailbox)) {
|
||||
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
|
||||
const delta = read ? -1 : 1;
|
||||
return {
|
||||
@@ -1285,7 +1404,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
|
||||
set((state) => {
|
||||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
if (currentMailboxIds.includes(mailbox.id)) {
|
||||
if (currentMailboxIds.includes(mailbox.id) || (!mailbox.isShared && mailbox.originalId ? currentMailboxIds.includes(mailbox.originalId) : false)) {
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: Math.max(0, mailbox.totalEmails - 1),
|
||||
@@ -1336,17 +1455,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const affected = emails.filter(e => idSet.has(e.id));
|
||||
|
||||
if (isUnifiedView) {
|
||||
// In unified view, emails may span accounts – group and dispatch per-account.
|
||||
const byAccount = new Map<string, string[]>();
|
||||
// In unified view, emails may span accounts – group by owning JMAP account
|
||||
// and dispatch through the login client that can reach each one. The login
|
||||
// client is keyed by `sourceClientAccountId` (a real AccountEntry.id); the
|
||||
// owning account is `sourceAccountId` (passed for owner-scoped routing).
|
||||
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
|
||||
for (const e of affected) {
|
||||
const acct = e.accountId || '__default__';
|
||||
if (!byAccount.has(acct)) byAccount.set(acct, []);
|
||||
byAccount.get(acct)!.push(e.id);
|
||||
const key = e.sourceAccountId || '__default__';
|
||||
if (!bySource.has(key)) bySource.set(key, { clientAccountId: e.sourceClientAccountId, ids: [] });
|
||||
bySource.get(key)!.ids.push(e.id);
|
||||
}
|
||||
await Promise.all(Array.from(byAccount.entries()).map(async ([acct, ids]) => {
|
||||
const acctClient = acct === '__default__' ? client : useAuthStore.getState().getClientForAccount(acct);
|
||||
await Promise.all(Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
|
||||
const acctClient = sourceAccountId === '__default__'
|
||||
? resolveActionClient(client)
|
||||
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
|
||||
if (!acctClient) return;
|
||||
await acctClient.batchMoveEmails(ids, jmapDestId);
|
||||
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
|
||||
await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId);
|
||||
}));
|
||||
} else {
|
||||
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
@@ -1372,7 +1497,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return next;
|
||||
})(),
|
||||
mailboxes: state.mailboxes.map(mb => {
|
||||
if (sourceMailboxIds.has(mb.id)) {
|
||||
if (sourceMailboxIds.has(mb.id) || (!mb.isShared && mb.originalId ? sourceMailboxIds.has(mb.originalId) : false)) {
|
||||
return {
|
||||
...mb,
|
||||
totalEmails: Math.max(0, mb.totalEmails - movedCount),
|
||||
@@ -1559,9 +1684,25 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
searchEmails: async (client, query) => {
|
||||
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
||||
try {
|
||||
const { isUnifiedView, unifiedRole } = get();
|
||||
const { isUnifiedView, unifiedRole, crossView } = get();
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
if (isUnifiedView && crossView) {
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0);
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||||
set({
|
||||
emails: result.emails,
|
||||
externalSearchResults: externals,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoading: false,
|
||||
unifiedErrors: result.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedView && unifiedRole) {
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
@@ -1611,7 +1752,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
advancedSearch: async (client) => {
|
||||
const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get();
|
||||
const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole, crossView } = get();
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
|
||||
if (searchAbortController) {
|
||||
@@ -1631,6 +1772,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
try {
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
if (isUnifiedView && crossView) {
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
|
||||
if (controller.signal.aborted) return;
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||||
set({
|
||||
emails: result.emails,
|
||||
externalSearchResults: externals,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoading: false,
|
||||
unifiedErrors: result.errors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedView && unifiedRole) {
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
@@ -1709,9 +1867,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
if (!email) return;
|
||||
|
||||
const isFlagged = email.keywords.$flagged || false;
|
||||
// In unified view route to the email's own account client. (#281)
|
||||
const { client: actionClient } = resolveEmailActionContext(email, client);
|
||||
await actionClient.toggleStar(emailId, !isFlagged);
|
||||
// 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) => ({
|
||||
@@ -1752,19 +1911,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const emailIdsArray = Array.from(selectedEmailIds);
|
||||
|
||||
if (get().isUnifiedView) {
|
||||
// Group emails by accountId for cross-account operations
|
||||
const emailsByAccount = new Map<string, string[]>();
|
||||
// Group by owning JMAP account; dispatch through the reaching login client.
|
||||
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
|
||||
for (const emailId of emailIdsArray) {
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
const acctId = email?.accountId || '__default__';
|
||||
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
|
||||
emailsByAccount.get(acctId)!.push(emailId);
|
||||
const key = email?.sourceAccountId || '__default__';
|
||||
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
|
||||
bySource.get(key)!.ids.push(emailId);
|
||||
}
|
||||
|
||||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||||
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
|
||||
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
|
||||
const acctClient = sourceAccountId === '__default__'
|
||||
? resolveActionClient(client)
|
||||
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
|
||||
if (!acctClient) return;
|
||||
await acctClient.batchMarkAsRead(ids, read);
|
||||
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
|
||||
await acctClient.batchMarkAsRead(ids, read, jmapAccountId);
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
} else {
|
||||
@@ -1783,8 +1945,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const updatedMailboxes = mailboxes.map(mailbox => {
|
||||
let deltaUnread = 0;
|
||||
affectedEmails.forEach(email => {
|
||||
if (email.mailboxIds?.[mailbox.id]) {
|
||||
const wasRead = email.keywords?.$seen === true;
|
||||
if (emailInMailbox(email, mailbox)) {
|
||||
const wasRead = email.keywords?.$seen;
|
||||
if (wasRead !== read) {
|
||||
deltaUnread += read ? -1 : 1;
|
||||
}
|
||||
@@ -1829,46 +1991,56 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
// Group emails by accountId (handles unified view and search results spanning accounts).
|
||||
const emailsByAccount = new Map<string, string[]>();
|
||||
// Group emails by 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) fall into '__default__' = active client.
|
||||
const accountMailboxes = get().accountMailboxes;
|
||||
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
|
||||
for (const emailId of emailIdsArray) {
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
const acctId = email?.accountId || '__default__';
|
||||
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
|
||||
emailsByAccount.get(acctId)!.push(emailId);
|
||||
const key = email?.sourceAccountId || '__default__';
|
||||
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
|
||||
bySource.get(key)!.ids.push(emailId);
|
||||
}
|
||||
|
||||
const getClient = (acctId: string) =>
|
||||
acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
|
||||
const getClient = (sourceAccountId: string, clientAccountId?: string) =>
|
||||
sourceAccountId === '__default__'
|
||||
? resolveActionClient(client)
|
||||
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
|
||||
const mailboxesFor = (sourceAccountId: string) =>
|
||||
sourceAccountId === '__default__' ? mailboxes : (accountMailboxes[sourceAccountId] ?? mailboxes);
|
||||
const jmapIdFor = (sourceAccountId: string) =>
|
||||
sourceAccountId === '__default__' ? undefined : sourceAccountId;
|
||||
|
||||
if (forceDestroy) {
|
||||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||||
const acctClient = getClient(acctId);
|
||||
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
|
||||
const acctClient = getClient(sourceAccountId, clientAccountId);
|
||||
if (!acctClient) return;
|
||||
await acctClient.batchDeleteEmails(ids);
|
||||
await acctClient.batchDeleteEmails(ids, jmapIdFor(sourceAccountId));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
} else {
|
||||
// Move to trash per account.
|
||||
const failedAccounts: string[] = [];
|
||||
const movedEmailIds = new Set<string>();
|
||||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||||
const acctClient = getClient(acctId);
|
||||
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
|
||||
const acctClient = getClient(sourceAccountId, clientAccountId);
|
||||
if (!acctClient) {
|
||||
failedAccounts.push(acctId);
|
||||
failedAccounts.push(sourceAccountId);
|
||||
return;
|
||||
}
|
||||
const trashMailbox = findTrashMailbox(mailboxes, {
|
||||
accountId: acctId === '__default__' ? undefined : acctId,
|
||||
const trashMailbox = findTrashMailbox(mailboxesFor(sourceAccountId), {
|
||||
accountId: jmapIdFor(sourceAccountId),
|
||||
});
|
||||
if (!trashMailbox) {
|
||||
// No trash for this account: skip rather than silently destroying.
|
||||
// The user asked to move to trash, not permanently delete.
|
||||
failedAccounts.push(acctId);
|
||||
failedAccounts.push(sourceAccountId);
|
||||
return;
|
||||
}
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead);
|
||||
await acctClient.batchMoveEmails(ids, trashId, jmapIdFor(sourceAccountId), alsoMarkRead);
|
||||
ids.forEach(id => movedEmailIds.add(id));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
@@ -1886,7 +2058,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
let deltaTotalEmails = 0;
|
||||
let deltaUnreadEmails = 0;
|
||||
deletedEmails.forEach(email => {
|
||||
if (email.mailboxIds?.[mailbox.id]) {
|
||||
if (emailInMailbox(email, mailbox)) {
|
||||
deltaTotalEmails--;
|
||||
if (!email.keywords?.$seen) deltaUnreadEmails--;
|
||||
}
|
||||
@@ -1921,7 +2093,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
let deltaUnreadEmails = 0;
|
||||
|
||||
deletedEmails.forEach(email => {
|
||||
if (email.mailboxIds?.[mailbox.id]) {
|
||||
if (emailInMailbox(email, mailbox)) {
|
||||
deltaTotalEmails--;
|
||||
if (!email.keywords?.$seen) {
|
||||
deltaUnreadEmails--;
|
||||
@@ -1962,19 +2134,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const emailIdsArray = Array.from(selectedEmailIds);
|
||||
|
||||
if (get().isUnifiedView) {
|
||||
// Group emails by accountId for cross-account operations
|
||||
const emailsByAccount = new Map<string, string[]>();
|
||||
// Group by owning JMAP account; dispatch through the reaching login client.
|
||||
const destMailbox = resolveActionMailboxes().find(mb => mb.id === toMailboxId);
|
||||
const jmapDestId = destMailbox?.originalId || toMailboxId;
|
||||
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
|
||||
for (const emailId of emailIdsArray) {
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
const acctId = email?.accountId || '__default__';
|
||||
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
|
||||
emailsByAccount.get(acctId)!.push(emailId);
|
||||
const key = email?.sourceAccountId || '__default__';
|
||||
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
|
||||
bySource.get(key)!.ids.push(emailId);
|
||||
}
|
||||
|
||||
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
|
||||
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
|
||||
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
|
||||
const acctClient = sourceAccountId === '__default__'
|
||||
? resolveActionClient(client)
|
||||
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
|
||||
if (!acctClient) return;
|
||||
await acctClient.batchMoveEmails(ids, toMailboxId);
|
||||
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
|
||||
await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId);
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
} else {
|
||||
@@ -2055,8 +2232,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// derive it from the email's own folders (preferring the unified role),
|
||||
// otherwise the active account's selected mailbox.
|
||||
const currentMailbox = get().isUnifiedView
|
||||
? (mailboxes.find(mb => email.mailboxIds?.[mb.id] && mb.role === get().unifiedRole)
|
||||
?? mailboxes.find(mb => email.mailboxIds?.[mb.id]))
|
||||
? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole)
|
||||
?? mailboxes.find(mb => emailInMailbox(email, mb)))
|
||||
: mailboxes.find(m => m.id === get().selectedMailbox);
|
||||
if (!currentMailbox) return;
|
||||
|
||||
@@ -2064,7 +2241,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emailId,
|
||||
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
|
||||
accountId,
|
||||
sourceAccountId: get().isUnifiedView ? email.accountId : undefined,
|
||||
sourceClientAccountId: get().isUnifiedView ? email.sourceClientAccountId : undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -2099,17 +2276,28 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
// Use cached original mailbox (more accurate for immediate undo)
|
||||
targetMailboxId = cachedData.originalMailboxId;
|
||||
accountId = cachedData.accountId;
|
||||
if (cachedData.sourceAccountId) {
|
||||
undoClient = useAuthStore.getState().getClientForAccount(cachedData.sourceAccountId) ?? undoClient;
|
||||
if (cachedData.sourceClientAccountId) {
|
||||
undoClient = useAuthStore.getState().getClientForAccount(cachedData.sourceClientAccountId) ?? undoClient;
|
||||
}
|
||||
get().spamUndoCache.delete(emailId);
|
||||
} else {
|
||||
// Fall back to finding Inbox (generic "not spam" button/menu)
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
accountId = currentMailbox?.accountId;
|
||||
// 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(e => e.id === emailId);
|
||||
let inboxMailboxes = mailboxes;
|
||||
if (get().isUnifiedView && listEmail?.sourceClientAccountId && listEmail?.sourceAccountId) {
|
||||
undoClient = useAuthStore.getState().getClientForAccount(listEmail.sourceClientAccountId) ?? undoClient;
|
||||
accountId = listEmail.sourceAccountId;
|
||||
inboxMailboxes = get().accountMailboxes[listEmail.sourceAccountId] ?? mailboxes;
|
||||
} else {
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
accountId = currentMailbox?.accountId;
|
||||
}
|
||||
|
||||
// Find inbox in same account
|
||||
const inboxMailbox = mailboxes.find(m =>
|
||||
const inboxMailbox = inboxMailboxes.find(m =>
|
||||
m.role === 'inbox' &&
|
||||
(accountId ? m.accountId === accountId : !m.accountId)
|
||||
);
|
||||
@@ -2124,7 +2312,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
try {
|
||||
await undoClient.undoSpam(emailId, targetMailboxId, accountId);
|
||||
// Refresh the view the user is actually looking at.
|
||||
if (get().isUnifiedView && get().unifiedRole) {
|
||||
if (get().isUnifiedView && get().crossView) {
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const accounts = await buildUnifiedAccountClients({ includeGroup });
|
||||
await get().fetchCrossView(accounts, get().crossView!);
|
||||
} else if (get().isUnifiedView && get().unifiedRole) {
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const accounts = await buildUnifiedAccountClients({ includeGroup });
|
||||
await get().fetchUnifiedEmails(accounts, get().unifiedRole!);
|
||||
@@ -2474,12 +2666,34 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ isLoadingThread: threadId });
|
||||
|
||||
try {
|
||||
// Determine accountId for shared folders
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
// 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)
|
||||
const threadEmail = get().emails.find(e => e.threadId === threadId);
|
||||
let actionClient = resolveActionClient(client);
|
||||
let accountId: string | undefined;
|
||||
if (get().isUnifiedView && threadEmail?.sourceClientAccountId && threadEmail?.sourceAccountId) {
|
||||
actionClient = useAuthStore.getState().getClientForAccount(threadEmail.sourceClientAccountId) ?? actionClient;
|
||||
accountId = threadEmail.sourceAccountId;
|
||||
} else {
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
}
|
||||
|
||||
// Fetch all emails in the thread
|
||||
const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId);
|
||||
const emails = await actionClient.getThreadEmails(threadId, accountId);
|
||||
|
||||
// Re-stamp the source reference so actions on thread emails resolve to the
|
||||
// right account (the fetched objects don't carry it).
|
||||
if (get().isUnifiedView && 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);
|
||||
@@ -2561,7 +2775,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const updatedMailboxes = state.mailboxes.map(mailbox => {
|
||||
let delta = 0;
|
||||
for (const email of affectedEmails) {
|
||||
if (email.mailboxIds?.[mailbox.id]) delta -= 1;
|
||||
if (emailInMailbox(email, mailbox)) delta -= 1;
|
||||
}
|
||||
if (delta === 0) return mailbox;
|
||||
return {
|
||||
@@ -2793,6 +3007,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
error: null,
|
||||
isUnifiedView: true,
|
||||
unifiedRole: role,
|
||||
crossView: null,
|
||||
selectedKeyword: null,
|
||||
});
|
||||
try {
|
||||
@@ -2856,10 +3071,50 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
fetchCrossView: async (accounts, view) => {
|
||||
set({
|
||||
isLoading: true,
|
||||
error: null,
|
||||
isUnifiedView: true,
|
||||
unifiedRole: null,
|
||||
crossView: view,
|
||||
selectedKeyword: null,
|
||||
});
|
||||
try {
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
const result = await fetchCrossViewEmails(accounts, view, emailsPerPage, 0);
|
||||
set({
|
||||
emails: result.emails,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoading: false,
|
||||
unifiedErrors: result.errors,
|
||||
});
|
||||
} catch (error) {
|
||||
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({ crossUnreadCount: getCrossUnreadTotal(accounts) });
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh cross-account counts:', error);
|
||||
}
|
||||
},
|
||||
|
||||
exitUnifiedView: () => {
|
||||
set({
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
crossView: null,
|
||||
unifiedErrors: new Map(),
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user