From a29c33b50a0305616ecefe3d7d06a2a460dfabdb Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:34:12 +0200 Subject: [PATCH] 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. --- FEATURES.md | 6 +- README.md | 2 +- app/(main)/[locale]/page.tsx | 183 +++++-- app/(main)/admin/_tabs/policy.tsx | 5 +- components/email/email-hover-actions.tsx | 25 +- components/email/email-list-item.tsx | 14 +- components/email/email-list.tsx | 13 +- components/email/thread-list-item.tsx | 57 ++- components/layout/sidebar.tsx | 37 +- components/settings/layout-settings.tsx | 28 +- lib/__tests__/unified-mailbox-cross.test.ts | 169 +++++++ lib/__tests__/unified-mailbox.test.ts | 4 + lib/admin/types.ts | 6 + lib/demo/demo-client.ts | 8 +- lib/jmap/client-interface.ts | 8 +- lib/jmap/client.ts | 16 +- lib/jmap/types.ts | 55 +- lib/unified-mailbox.ts | 171 ++++++- locales/cs/common.json | 18 +- locales/da/common.json | 18 +- locales/de/common.json | 18 +- locales/en/common.json | 18 +- locales/es/common.json | 18 +- locales/fr/common.json | 18 +- locales/hu/common.json | 18 +- locales/it/common.json | 18 +- locales/ja/common.json | 18 +- locales/ko/common.json | 18 +- locales/lv/common.json | 18 +- locales/nl/common.json | 18 +- locales/pl/common.json | 18 +- locales/pt/common.json | 18 +- locales/ro/common.json | 18 +- locales/ru/common.json | 18 +- locales/tr/common.json | 18 +- locales/uk/common.json | 18 +- locales/zh/common.json | 18 +- .../email-store-unified-actions.test.ts | 37 +- stores/__tests__/folder-management.test.ts | 38 ++ stores/email-store.ts | 477 ++++++++++++++---- stores/settings-store.ts | 16 + 41 files changed, 1491 insertions(+), 226 deletions(-) create mode 100644 lib/__tests__/unified-mailbox-cross.test.ts diff --git a/FEATURES.md b/FEATURES.md index 9e9d001f..3fb35ec4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,7 +4,9 @@ - Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables) - Gmail-style threading with inline expansion and an optional conversation toggle -- Unified mailbox view across all connected accounts +- Unified mailbox view across all connected accounts – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in +- Cross-account "All accounts" views – All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message +- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list - Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom - Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies - Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning @@ -115,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL - Configurable signature position (above or below quoted text) - Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions - Shared folders across accounts +- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there – open, mark read, spam / not-spam, move, delete, and archive – with folder unread counts kept in sync - Multiple JMAP servers per deployment with optional auto-pick by email domain - Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`) @@ -122,6 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL - Web setup wizard for first launch – guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required - Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page +- Admin policy gates for the aggregate mail views – enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle - Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps) - File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts - Admin toggle for search-engine indexing (`robots.txt` / `noindex`) diff --git a/README.md b/README.md index 769735cc..628d9acf 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login: -- **Mail** – threading, unified inbox, full-text search, Sieve filters, S/MIME, templates +- **Mail** – threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates - **Calendar** – month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions - **Contacts** – multiple address books, groups, vCard import/export - **Files** – Stalwart's JMAP FileNode storage with previews and folder upload diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 1edd0997..d13164e5 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -11,7 +11,7 @@ import type { ComposerDraftData } from "@/components/email/email-composer"; import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; -import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types"; +import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; import { useAccountStore } from "@/stores/account-store"; import { usePolicyStore } from "@/stores/policy-store"; import type { UnifiedAccountClient } from "@/lib/unified-mailbox"; @@ -319,7 +319,10 @@ export default function Home() { clearPendingUndoSend, pendingUndoSend, fetchUnifiedEmails: fetchUnifiedEmailsAction, + fetchCrossView: fetchCrossViewAction, refreshUnifiedCounts, + refreshCrossCounts, + crossUnreadCount, exitUnifiedView, emptyMailbox, markMailboxAsRead, @@ -347,6 +350,19 @@ export default function Home() { const delayedSendSupported = client?.hasDelayedSend() ?? true; const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled')); const showAllMailMailbox = allMailViewEnabled && enableAllMailView; + + // Cross-account "All accounts" views: a sub-feature of the unified mailbox, so + // they require Unified Mailbox to be enabled, plus the admin gate and the + // per-user setting. Hooks are called unconditionally. + const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled')); + const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled')); + const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled')); + const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView); + const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView); + const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView); + const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView; + const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView; + const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView; const activeEmails = isScheduledView ? scheduledEmails : emails; const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; @@ -986,14 +1002,16 @@ export default function Home() { // recounting happened"). The Pro shell always renders the unified mailbox // regardless of the user setting, so refresh when embedded too. useEffect(() => { - if (!enableUnifiedMailbox && !isEmbedded) return; + const anyCross = showCrossUnread || showCrossStarred || showCrossAll; + if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return; if (!isAuthenticated || !client) return; buildPopulatedUnifiedAccounts().then((built) => { const hasGroupEntry = built.some((b) => b.isShared); + if (anyCross) refreshCrossCounts(built); if (built.length < 2 && !hasGroupEntry && !isEmbedded) return; refreshUnifiedCounts(built); }); - }, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]); + }, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]); // System-notification click handler. The push SW navigates the user back // here with `?email=` (specific email it built the toast from) or @@ -1039,8 +1057,8 @@ export default function Home() { // Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before // calling selectEmail on the stub), to avoid a duplicate request. if (!selectedEmail.bodyValues && !isLoadingEmail) { - const perAccountClient = isUnifiedView && selectedEmail.accountId - ? useAuthStore.getState().getClientForAccount(selectedEmail.accountId) + const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId + ? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId) : undefined; const fetchClient = perAccountClient ?? client; setLoadingEmail(true); @@ -1177,8 +1195,15 @@ export default function Home() { const emailState = useEmailStore.getState(); const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { - const accountId = client.getAccountId(); - const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId); + // Route to the email's own account so shared/group threads refresh + // from the right server, not the active one. (#281) + const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId + ? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client) + : client; + const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId + ? repliedEmail.sourceAccountId + : client.getAccountId(); + const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId); if (fullEmails.length > 0) { useEmailStore.setState((state) => { const c = new Map(state.threadEmailsCache); @@ -1414,10 +1439,11 @@ export default function Home() { if (!client || !emailToDelete) return; // In unified view the trash destination and current-folder check must come - // from the email's own account, not the active one. (#281) + // from the email's own account, not the active one. The owning account's + // mailbox list is cached under its JMAP id (`sourceAccountId`). (#281) const actionMailboxes = - isUnifiedView && emailToDelete.accountId - ? (accountMailboxes[emailToDelete.accountId] ?? mailboxes) + isUnifiedView && emailToDelete.sourceAccountId + ? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes) : mailboxes; // Check if we're currently in the trash or junk folder. In unified view the @@ -1446,11 +1472,17 @@ export default function Home() { console.error("Failed to permanently delete email:", error); } } else { - // Not in trash: always move to trash (in the email's own account). + // Not in trash: always move to trash (in the email's own account). Scope + // the trash lookup to the email's account: for a shared/group source every + // mailbox in the list is `isShared`, so we match by accountId instead of + // excluding shared (otherwise no trash is found and the delete fails). (#281) + const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined; + const matchesScope = (m: Mailbox) => + sourceAccountId ? m.accountId === sourceAccountId : !m.isShared; const trashMailbox = - actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ?? + actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ?? actionMailboxes.find(m => { - if (m.isShared) return false; + if (!matchesScope(m)) return false; const lower = m.name.toLowerCase(); return lower.includes('trash') || lower.includes('deleted'); }); @@ -1473,11 +1505,14 @@ export default function Home() { if (!client || !emailToArchive) return; // In unified view the archive folder (and any year/month subfolders we - // create) must live in the email's own account, reached through that - // account's client. (#281) - const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined; - const archiveClient = archiveAccountId - ? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client) + // create) must live in the email's own account, reached through the login it + // is reachable via (`sourceClientAccountId`) and routed to its owning JMAP + // account (`sourceAccountId`). For personal sources these resolve to the + // account itself, so behavior is unchanged. (#281) + const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined; + const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined; + const archiveClient = archiveClientId + ? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client) : client; // Read fresh mailboxes from the store – batch archive calls this in a loop, // and each iteration needs to see folders created by prior iterations. @@ -1512,7 +1547,7 @@ export default function Home() { m => m.name === year && m.parentId === archiveId ); if (!yearMailbox) { - yearMailbox = await archiveClient.createMailbox(year, archiveId); + yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId); await refreshMailboxes(); } @@ -1525,7 +1560,7 @@ export default function Home() { m => m.name === month && m.parentId === yearId ); if (!monthMailbox) { - monthMailbox = await archiveClient.createMailbox(month, yearId); + monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId); await refreshMailboxes(); } await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id); @@ -1618,7 +1653,7 @@ export default function Home() { }); } else { const jmapKey = `$label:${color}`; - if (keywords[jmapKey] === true) { + if (keywords[jmapKey]) { // Toggle off if already active keywords[jmapKey] = false; } else { @@ -1714,6 +1749,28 @@ export default function Home() { return; } + if (isCrossViewId(mailboxId)) { + setScheduledView(false); + const view = CROSS_VIEW_BY_ID[mailboxId]; + if (!view) return; + + selectMailbox(mailboxId); + selectEmail(null); + + if (isMobile) { + setSidebarOpen(false); + setActiveView("list"); + } + if (isTablet) { + setTabletListVisible(true); + } + + const populated = await buildPopulatedUnifiedAccounts(); + await fetchCrossViewAction(populated, view); + refreshCrossCounts(populated); + return; + } + if (isUnifiedView) { exitUnifiedView(); } @@ -2227,8 +2284,15 @@ export default function Home() { const emailState = useEmailStore.getState(); const repliedEmail = emailState.emails.find(e => e.id === originalEmailId); if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) { - const accountId = client.getAccountId(); - const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId); + // Route to the email's own account so shared/group threads refresh from + // the right server, not the active one. (#281) + const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId + ? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client) + : client; + const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId + ? repliedEmail.sourceAccountId + : client.getAccountId(); + const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId); if (fullEmails.length > 0) { useEmailStore.setState((state) => { const c = new Map(state.threadEmailsCache); @@ -2292,21 +2356,25 @@ export default function Home() { // Fetch the full content try { - // In unified view each email carries its own accountId. Use that - // account's client so we fetch from the server that actually owns it. - const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined; - const perAccountClient = emailAccountId - ? useAuthStore.getState().getClientForAccount(emailAccountId) + // In unified view each email carries its source reference: the login it is + // reachable through (`sourceClientAccountId`) and its owning JMAP account + // (`sourceAccountId`). Resolve both so we fetch from the server that actually + // owns it — works uniformly for personal and shared/group sources, since for + // personal the owning account equals the client's primary (no-op). (#281) + const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined; + const perAccountClient = sourceClientId + ? useAuthStore.getState().getClientForAccount(sourceClientId) : undefined; const fetchClient = perAccountClient ?? client; - // For shared folders on the primary client, we still need to pass the - // shared account's id. In unified view we use the per-account client - // directly, so no explicit accountId is needed. - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - const accountId = perAccountClient - ? undefined - : mailbox?.isShared ? mailbox.accountId : undefined; + const accountId = isUnifiedView + ? listEmail?.sourceAccountId + : (() => { + // Non-unified: shared folders on the active client still need their + // owner accountId passed explicitly. + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + return mailbox?.isShared ? mailbox.accountId : undefined; + })(); const fullEmail = await fetchClient.getEmail(email.id, accountId); if (fullEmail) { @@ -2318,9 +2386,13 @@ export default function Home() { fullEmail.isScheduled = true; fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled; } - if (emailAccountId) { - fullEmail.accountId = emailAccountId; - fullEmail.accountLabel = listEmail?.accountLabel; + // Re-stamp the source reference so later actions on the open email + // resolve to the right account (the fetched object lacks these). + if (isUnifiedView && listEmail) { + fullEmail.accountId = listEmail.accountId; + fullEmail.accountLabel = listEmail.accountLabel; + fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId; + fullEmail.sourceAccountId = listEmail.sourceAccountId; } selectEmail(fullEmail); // Mark-as-read logic is now handled by useEffect @@ -2369,8 +2441,27 @@ export default function Home() { setActiveView("viewer"); try { - // Fetch complete thread emails - const emails = await client.getThreadEmails(thread.threadId); + // In unified/aggregate views the thread may belong to another (possibly + // shared/group) account. Route the fetch to the login it's reachable + // through (`sourceClientAccountId`) and pass its owning JMAP account + // (`sourceAccountId`) so the thread loads from the right server instead of + // the active one (which doesn't have it → empty/body-less). (#281) + const ref = thread.emails?.[0]; + const threadClient = isUnifiedView && ref?.sourceClientAccountId + ? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client) + : client; + const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined; + const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId); + // Re-stamp the source reference so conversation actions (reply/move/…) + // resolve to the right account; the fetched objects don't carry it. + if (isUnifiedView && ref) { + for (const e of emails) { + e.accountId = ref.accountId; + e.accountLabel = ref.accountLabel; + e.sourceClientAccountId = ref.sourceClientAccountId; + e.sourceAccountId = ref.sourceAccountId; + } + } setConversationEmails(emails); } catch (error) { console.error('Failed to fetch thread emails:', error); @@ -2518,6 +2609,10 @@ export default function Home() { scheduledTotal={scheduledTotal} showScheduledMailbox={delayedSendSupported} showAllMailMailbox={showAllMailMailbox} + showCrossUnread={showCrossUnread} + showCrossStarred={showCrossStarred} + showCrossAll={showCrossAll} + crossUnreadCount={crossUnreadCount} onMailboxSelect={handleMailboxSelect} onTagSelect={handleTagSelect} onUnreadFilterClick={handleUnreadFilterClick} @@ -2685,19 +2780,19 @@ export default function Home() { icon={} label={t("advanced_search.has_attachment")} value={searchFilters.hasAttachment} - onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }} + onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }} /> } label={t("advanced_search.starred")} value={searchFilters.isStarred} - onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }} + onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }} /> : } label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")} value={searchFilters.isUnread} - onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }} + onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }} />
@@ -3142,7 +3237,7 @@ export default function Home() { }} currentUserEmail={client?.getUsername()} currentUserName={client?.getUsername()?.split("@")[0]} - currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} + currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)} mailboxes={mailboxes} selectedMailbox={selectedMailbox} onMoveToMailbox={async (mailboxId) => { diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index ede0e736..458a2bb1 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -21,7 +21,10 @@ const FEATURE_GATE_LABELS: Partial void; onSetColorTag?: (color: string | null) => void; onMarkAsSpam?: () => void; + // When the email lives in a junk folder (incl. the aggregate "All Junk" view) + // the spam quick-action flips to "not spam". + isInJunk?: boolean; + onUndoSpam?: () => void; } const ACTION_CONFIG: Record state.hoverActions); const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode); @@ -106,7 +112,8 @@ export function EmailHoverActions({ onSetColorTag?.(null); break; case "spam": - onMarkAsSpam?.(); + if (isInJunk) onUndoSpam?.(); + else onMarkAsSpam?.(); break; } }; @@ -116,20 +123,28 @@ export function EmailHoverActions({ if (!config) return null; const Icon = config.icon; + // In a junk context the spam action becomes "not spam". + const isNotSpam = actionId === "spam" && isInJunk; const DisplayIcon = actionId === "markRead" ? (isUnread ? MailOpen : Mail) : actionId === "star" && isStarred ? Star - : Icon; + : isNotSpam + ? ShieldCheck + : Icon; + const title = isNotSpam ? t("not_spam") : t(config.titleKey); + const className = isNotSpam + ? "hover:text-green-600 dark:hover:text-green-400" + : config.className; return (
); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index bad9db10..9c3ec0c7 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -108,8 +108,18 @@ export function EmailList({ clearSearchFilters, advancedSearch, searchQuery, + isUnifiedView, + unifiedRole, } = useEmailStore(); + // In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so + // there is no concrete mailbox to read the role from. Fall back to the unified + // role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside + // that role's folder. + const effectiveMailboxRole = + mailboxes.find(m => m.id === selectedMailbox)?.role + ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); + const disableThreading = useSettingsStore((state) => state.disableThreading); const threadGroups = useMemo(() => { @@ -499,6 +509,7 @@ export function EmailList({ onArchive={onArchive ? (email) => onArchive(email) : undefined} onSetColorTag={onSetColorTag} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} + onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined} /> ); @@ -532,7 +543,7 @@ export function EmailList({ menuRef={menuRef} mailboxes={mailboxes} selectedMailbox={selectedMailbox} - currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role} + currentMailboxRole={effectiveMailboxRole} isMultiSelect={selectedEmailIds.has(contextMenu.data.id)} selectedCount={selectedEmailIds.size} onReply={() => onReply?.(contextMenu.data!)} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 9c2f3287..0125333c 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -2,10 +2,10 @@ import React, { useCallback } from "react"; import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils"; -import { Email, ThreadGroup } from "@/lib/jmap/types"; +import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/ui/avatar"; -import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react"; +import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; @@ -17,6 +17,23 @@ import { ThreadEmailItem } from "./thread-email-item"; import { EmailHoverActions } from "./email-hover-actions"; import { useTranslations } from "next-intl"; +/** + * Small chip showing the originating folder of a message, rendered in the + * aggregate "All …" views (All Mail / unified / cross-account) where rows come + * from different folders. `email.sourceFolder` is stamped at fetch time. + */ +function SourceFolderTag({ name }: { name: string }) { + return ( + + + {name} + + ); +} + interface ThreadListItemProps { thread: ThreadGroup; isExpanded: boolean; @@ -35,6 +52,7 @@ interface ThreadListItemProps { onArchive?: (email: Email) => void; onSetColorTag?: (emailId: string, color: string | null) => void; onMarkAsSpam?: (email: Email) => void; + onUndoSpam?: (email: Email) => void; } interface SingleEmailItemProps { @@ -51,18 +69,22 @@ interface SingleEmailItemProps { onArchive?: () => void; onSetColorTag?: (color: string | null) => void; onMarkAsSpam?: () => void; + onUndoSpam?: () => void; } const SingleEmailItem = React.forwardRef( - function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) { + function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) { const t = useTranslations('email_viewer'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; const isAnswered = email.keywords?.$answered; const isForwarded = email.keywords?.$forwarded; - const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore(); - // In Sent/Drafts folders, show recipient instead of sender (which is always "me") - const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; + const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore(); + // In Sent/Drafts folders, show recipient instead of sender (which is always + // "me"). In aggregate role-views the selected mailbox is virtual → fall back + // to the unified role so junk-contextual UI and avatar hiding work. + const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role + ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0]; const emailKeywords = useSettingsStore((state) => state.emailKeywords); @@ -71,7 +93,8 @@ const SingleEmailItem = React.forwardRef( const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; - const isUnifiedView = useEmailStore((state) => state.isUnifiedView); + // Show the originating folder in the aggregate "All …" views. + const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const isChecked = selectedEmailIds.has(email.id); @@ -246,6 +269,7 @@ const SingleEmailItem = React.forwardRef( {resolvedKeywordDefs.map((kd) => ( ))} + {showSourceFolder && } {scheduledSendLabel ? ( ( {kd.label} ))} + {showSourceFolder && } {scheduledSendLabel ? ( ( onArchive={onArchive} onSetColorTag={onSetColorTag} onMarkAsSpam={onMarkAsSpam} + onUndoSpam={onUndoSpam} + isInJunk={currentMailboxRole === 'junk'} /> )} @@ -396,6 +423,7 @@ export const ThreadListItem = React.forwardRef state.getAccountById); const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined; - // In Sent/Drafts folders, show recipient instead of sender (which is always "me") - const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role; + // In Sent/Drafts folders, show recipient instead of sender (which is always + // "me"). Aggregate role-views use a virtual selected mailbox → fall back to + // the unified role so junk-contextual UI and avatar hiding work. + const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role + ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts'; const displayNames = showRecipient ? Array.from(new Set( @@ -472,6 +504,7 @@ export const ThreadListItem = React.forwardRef onArchive(latestEmail) : undefined} onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} + onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} /> ); } @@ -682,6 +715,7 @@ export const ThreadListItem = React.forwardRef )} + {showSourceFolder && } {scheduledSendLabel ? ( )} + {showSourceFolder && } {scheduledSendLabel ? ( onArchive(latestEmail) : undefined} onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined} onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined} + onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined} + isInJunk={currentMailboxRole === 'junk'} /> )} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 53655202..be366652 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -34,13 +34,14 @@ import { CalendarClock, BellOff, Mails, + MailOpen, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils"; import { Mailbox } from "@/lib/jmap/types"; import { useContextMenu } from "@/hooks/use-context-menu"; import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu"; import { useAccountStore } from '@/stores/account-store'; -import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types'; +import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types'; import type { UnifiedMailboxRole } from '@/lib/jmap/types'; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useMailboxDrop } from "@/hooks/use-mailbox-drop"; @@ -79,6 +80,12 @@ interface SidebarProps { showScheduledMailbox?: boolean; /** Gated "All Mail" virtual folder that merges all of the account's folders. */ showAllMailMailbox?: boolean; + /** Gated cross-account views in the "All accounts" section. */ + showCrossUnread?: boolean; + showCrossStarred?: boolean; + showCrossAll?: boolean; + /** Unread total across all cross-view folders (badge for unread/all). */ + crossUnreadCount?: number; className?: string; /** * Multi-account (Pro) mode props. When `multiAccountMode` is true, the @@ -681,6 +688,10 @@ export function Sidebar({ scheduledTotal = 0, showScheduledMailbox = false, showAllMailMailbox = false, + showCrossUnread = false, + showCrossStarred = false, + showCrossAll = false, + crossUnreadCount = 0, className, multiAccountMode = false, accountMailboxes, @@ -991,7 +1002,7 @@ export function Sidebar({ isCollapsed={isCollapsed} /> )} - {showUnified && ( + {(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
{((unifiedExpanded && !isCollapsed) || isCollapsed) && ( <> - {unifiedCounts.map((count) => { + {showUnified && unifiedCounts.map((count) => { const unifiedId = UNIFIED_MAILBOX_IDS[count.role]; const Icon = getUnifiedIcon(count.role); const isSelected = !selectedKeyword && selectedMailbox === unifiedId; @@ -1020,6 +1031,26 @@ export function Sidebar({ /> ); })} + {[ + { show: showCrossUnread, id: CROSS_VIEW_IDS.unread, Icon: MailOpen, label: t('unified_all_unread'), unread: crossUnreadCount }, + { show: showCrossStarred, id: CROSS_VIEW_IDS.starred, Icon: Star, label: t('unified_all_starred'), unread: undefined as number | undefined }, + { show: showCrossAll, id: CROSS_VIEW_IDS.all, Icon: Mails, label: t('unified_all_mail'), unread: crossUnreadCount }, + ].map(({ show, id, Icon, label, unread }) => { + if (!show) return null; + const isSelected = !selectedKeyword && selectedMailbox === id; + return ( + } + label={label} + depth={0} + isSelected={isSelected} + unread={unread} + onClick={() => onMailboxSelect?.(id)} + isCollapsed={isCollapsed} + /> + ); + })} )}
diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx index b069a0af..c86eec9e 100644 --- a/components/settings/layout-settings.tsx +++ b/components/settings/layout-settings.tsx @@ -118,12 +118,18 @@ function MailLayoutPreview({ export function LayoutSettings() { const t = useTranslations('settings.appearance'); const tEmail = useTranslations('settings.email_behavior'); - const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore(); + const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore(); const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore(); const accounts = useAccountStore(s => s.accounts); const mailboxes = useEmailStore(s => s.mailboxes); const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]); const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled'); + // Cross-account "All accounts" views, each gated independently by the admin. + const crossViews = [ + { setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' }, + { setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' }, + { setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' }, + ] as const; // Own (non-shared) folders and the current All Mail selection. `null` = // never configured, which defaults to all non-special (no-role) folders. @@ -226,6 +232,26 @@ export function LayoutSettings() { )} + {enableUnifiedMailbox && crossViews.some(c => c.allowed) && ( +
+ {crossViews.map(({ setting, value, allowed, labelKey, descKey }) => ( + allowed && !isSettingHidden(setting) && ( + + updateSetting(setting, v)} + /> + + ) + ))} +
+ )} + {allMailViewAllowed && !isSettingHidden('enableAllMailView') && ( + ({ id, name: id, role, unreadEmails: unread, totalEmails: 0, originalId } as unknown as Mailbox); + +const makeAccount = ( + over: Partial & { accountId: string }, + clientImpl: Partial = {}, +): UnifiedAccountClient => ({ + accountLabel: over.accountId, + mailboxes: [], + client: clientImpl as unknown as IJMAPClient, + clientAccountId: over.accountId, + jmapAccountId: over.accountId, + ...over, +}); + +describe('getCrossIncludedMailboxes', () => { + it('excludes junk/sent/archive/trash/drafts, keeps inbox + custom folders', () => { + const account = makeAccount({ + accountId: 'a', + mailboxes: [ + mb('inbox', 'inbox'), + mb('projects', undefined), + mb('junk', 'junk'), + mb('sent', 'sent'), + mb('archive', 'archive'), + mb('trash', 'trash'), + mb('drafts', 'drafts'), + ], + }); + const ids = getCrossIncludedMailboxes(account).map((m) => m.id); + expect(ids).toEqual(['inbox', 'projects']); + }); +}); + +describe('buildCrossFilter', () => { + it('all → single inMailbox for one folder', () => { + expect(buildCrossFilter('all', ['m1'])).toEqual({ inMailbox: 'm1' }); + }); + + it('all → OR of inMailbox for multiple folders', () => { + expect(buildCrossFilter('all', ['m1', 'm2'])).toEqual({ + operator: 'OR', + conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }], + }); + }); + + it('unread → AND(membership, notKeyword $seen)', () => { + expect(buildCrossFilter('unread', ['m1', 'm2'])).toEqual({ + operator: 'AND', + conditions: [ + { operator: 'OR', conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }] }, + { notKeyword: '$seen' }, + ], + }); + }); + + it('starred → AND(membership, hasKeyword $flagged)', () => { + expect(buildCrossFilter('starred', ['m1'])).toEqual({ + operator: 'AND', + conditions: [{ inMailbox: 'm1' }, { hasKeyword: '$flagged' }], + }); + }); +}); + +describe('getCrossUnreadTotal', () => { + it('sums unread across included folders of every account, ignoring excluded roles', () => { + const a = makeAccount({ + accountId: 'a', + mailboxes: [mb('inbox', 'inbox', 3), mb('proj', undefined, 2), mb('junk', 'junk', 50)], + }); + const b = makeAccount({ + accountId: 'b', + mailboxes: [mb('inbox', 'inbox', 5), mb('sent', 'sent', 99)], + }); + expect(getCrossUnreadTotal([a, b])).toBe(10); + }); +}); + +describe('resolveSourceFolderName', () => { + const emailIn = (ids: string[]): Email => + ({ mailboxIds: Object.fromEntries(ids.map((id) => [id, true])) } as unknown as Email); + + it('returns the name of the folder the email is in (personal account)', () => { + const boxes = [mb('inbox', 'inbox'), mb('proj', undefined)]; + expect(resolveSourceFolderName(emailIn(['proj']), boxes)).toBe('proj'); + }); + + it('matches shared mailboxes by originalId (email keyed by owner-side id)', () => { + // shared mailbox: namespaced store id, but email.mailboxIds uses originalId + const shared = { id: 'owner:inbox', role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId: 'orig-inbox', name: 'Team Inbox' } as unknown as Mailbox; + expect(resolveSourceFolderName(emailIn(['orig-inbox']), [shared])).toBe('Team Inbox'); + }); + + it('returns undefined when no known folder contains the email', () => { + expect(resolveSourceFolderName(emailIn(['unknown']), [mb('inbox', 'inbox')])).toBeUndefined(); + }); +}); + +describe('fetchCrossViewEmails', () => { + it('merges + date-sorts across accounts and stamps account info', async () => { + const clientA = { + advancedSearchEmails: vi.fn().mockResolvedValue({ + emails: [{ id: 'a1', receivedAt: '2026-01-01T10:00:00Z' } as Email], + total: 1, + hasMore: false, + }), + }; + const clientB = { + advancedSearchEmails: vi.fn().mockResolvedValue({ + emails: [{ id: 'b1', receivedAt: '2026-01-02T10:00:00Z' } as Email], + total: 1, + hasMore: true, + }), + }; + const a = makeAccount({ accountId: 'a', accountLabel: 'A', mailboxes: [mb('inbox', 'inbox')] }, clientA); + const b = makeAccount({ accountId: 'b', accountLabel: 'B', mailboxes: [mb('inbox', 'inbox')] }, clientB); + + const result = await fetchCrossViewEmails([a, b], 'all', 50, 0); + + expect(result.emails.map((e) => e.id)).toEqual(['b1', 'a1']); // newest first + expect(result.emails[0].accountId).toBe('b'); + expect(result.emails[1].accountLabel).toBe('A'); + expect(result.total).toBe(2); + expect(result.hasMore).toBe(true); + }); + + it('resolves shared folders via originalId + owner accountId', async () => { + const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false }); + const shared = makeAccount( + { accountId: 'owner-1', accountLabel: 'Shared', isShared: true, mailboxes: [mb('ns:inbox', 'inbox', 0, 'orig-inbox')] }, + { advancedSearchEmails }, + ); + + await fetchCrossViewEmails([shared], 'unread', 50, 0); + + const [filter, accountId] = advancedSearchEmails.mock.calls[0]; + expect(accountId).toBe('owner-1'); + // filter membership uses the originalId, not the namespaced id + expect(JSON.stringify(filter)).toContain('orig-inbox'); + expect(JSON.stringify(filter)).not.toContain('ns:inbox'); + }); + + it('collects per-account errors without failing the whole fan-out', async () => { + const ok = makeAccount( + { accountId: 'ok', mailboxes: [mb('inbox', 'inbox')] }, + { advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [{ id: 'x', receivedAt: '2026-01-01T00:00:00Z' } as Email], total: 1, hasMore: false }) }, + ); + const bad = makeAccount( + { accountId: 'bad', mailboxes: [mb('inbox', 'inbox')] }, + { advancedSearchEmails: vi.fn().mockRejectedValue(new Error('boom')) }, + ); + + const result = await fetchCrossViewEmails([ok, bad], 'all', 50, 0); + expect(result.emails.map((e) => e.id)).toEqual(['x']); + expect(result.errors.get('bad')).toBe('boom'); + }); +}); diff --git a/lib/__tests__/unified-mailbox.test.ts b/lib/__tests__/unified-mailbox.test.ts index e74b574a..a03c90b3 100644 --- a/lib/__tests__/unified-mailbox.test.ts +++ b/lib/__tests__/unified-mailbox.test.ts @@ -28,6 +28,8 @@ function makeAccount( accountLabel: over.accountId, mailboxes: [], client: clientImpl as unknown as IJMAPClient, + clientAccountId: over.accountId, + jmapAccountId: over.accountId, ...over, }; } @@ -71,6 +73,8 @@ describe('fetchUnifiedEmails', () => { const a2 = result.emails.find((e) => e.id === 'a2')!; expect(a2.accountId).toBe('A'); expect(a2.accountLabel).toBe('Account A'); + expect(a2.sourceClientAccountId).toBe('A'); + expect(a2.sourceAccountId).toBe('A'); // getEmails called with (mailboxId, accountId=undefined for personal, limit, position) expect(acc1.client.getEmails).toHaveBeenCalledWith('a-in', undefined, 20, 0); }); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 42bae523..1696014d 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -60,6 +60,9 @@ export interface FeatureGates { filesEnabled: boolean; contactsEnabled: boolean; allMailViewEnabled: boolean; + crossUnreadViewEnabled: boolean; + crossStarredViewEnabled: boolean; + crossAllViewEnabled: boolean; } export const DEFAULT_FEATURE_GATES: FeatureGates = { @@ -81,6 +84,9 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = { filesEnabled: true, contactsEnabled: true, allMailViewEnabled: false, + crossUnreadViewEnabled: false, + crossStarredViewEnabled: false, + crossAllViewEnabled: false, }; export interface ThemePolicy { diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index cc20d52d..9d662844 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -121,7 +121,7 @@ export class DemoJMAPClient implements IJMAPClient { async getMailboxes(_accountId?: string): Promise { return [...this.data.mailboxes]; } async getAllMailboxes(): Promise { return [...this.data.mailboxes]; } - async createMailbox(name: string, parentId?: string): Promise { + async createMailbox(name: string, parentId?: string, _accountId?: string): Promise { const mb: Mailbox = { id: generateDemoId('mailbox'), name, @@ -222,7 +222,7 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } - async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise { + async batchMarkAsRead(emailIds: string[], read: boolean = true, _accountId?: string): Promise { for (const id of emailIds) { const email = this.data.emails.find(e => e.id === id); if (email) { @@ -233,7 +233,7 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } - async toggleStar(emailId: string, starred: boolean): Promise { + async toggleStar(emailId: string, starred: boolean, _accountId?: string): Promise { const email = this.data.emails.find(e => e.id === emailId); if (!email) return; if (starred) email.keywords.$flagged = true; @@ -275,7 +275,7 @@ export class DemoJMAPClient implements IJMAPClient { this.recalcMailboxCounts(); } - async batchDeleteEmails(emailIds: string[]): Promise { + async batchDeleteEmails(emailIds: string[], _accountId?: string): Promise { const idSet = new Set(emailIds); this.data.emails = this.data.emails.filter(e => !idSet.has(e.id)); this.recalcMailboxCounts(); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 0624861e..1cbbe0dc 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -73,7 +73,7 @@ export interface IJMAPClient { // ── Mailboxes ───────────────────────────────────────────────── getMailboxes(accountId?: string): Promise; getAllMailboxes(): Promise; - createMailbox(name: string, parentId?: string): Promise; + createMailbox(name: string, parentId?: string, accountId?: string): Promise; updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise; deleteMailbox(mailboxId: string): Promise; @@ -92,14 +92,14 @@ export interface IJMAPClient { // ── Email mutations ─────────────────────────────────────────── markAsRead(emailId: string, read?: boolean, accountId?: string): Promise; - batchMarkAsRead(emailIds: string[], read?: boolean): Promise; - toggleStar(emailId: string, starred: boolean): Promise; + batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise; + toggleStar(emailId: string, starred: boolean, accountId?: string): Promise; updateEmailKeywords(emailId: string, keywords: Record): Promise; setKeyword(emailId: string, keyword: string): Promise; migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string, accountId?: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; - batchDeleteEmails(emailIds: string[]): Promise; + batchDeleteEmails(emailIds: string[], accountId?: string): Promise; batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; batchArchiveEmails( emails: Array<{ id: string; receivedAt: string }>, diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index c7710078..eb15ff82 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1273,19 +1273,19 @@ export class JMAPClient implements IJMAPClient { ]); } - async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise { + async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise { if (emailIds.length === 0) return; const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }])); await this.request([ - ["Email/set", { accountId: this.accountId, update: updates }, "0"], + ["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"], ]); } - async toggleStar(emailId: string, starred: boolean): Promise { + async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise { await this.request([ ["Email/set", { - accountId: this.accountId, + accountId: accountId || this.accountId, update: { [emailId]: { "keywords/$flagged": starred, @@ -1391,12 +1391,12 @@ export class JMAPClient implements IJMAPClient { ]); } - async batchDeleteEmails(emailIds: string[]): Promise { + async batchDeleteEmails(emailIds: string[], accountId?: string): Promise { if (emailIds.length === 0) return; await this.request([ ["Email/set", { - accountId: this.accountId, + accountId: accountId || this.accountId, destroy: emailIds, }, "0"], ]); @@ -1709,7 +1709,7 @@ export class JMAPClient implements IJMAPClient { ]); } - async createMailbox(name: string, parentId?: string): Promise { + async createMailbox(name: string, parentId?: string, accountId?: string): Promise { const createId = `new-${Date.now()}`; const createData: Record = { name }; if (parentId) { @@ -1718,7 +1718,7 @@ export class JMAPClient implements IJMAPClient { const response = await this.request([ ["Mailbox/set", { - accountId: this.accountId, + accountId: accountId || this.accountId, create: { [createId]: createData }, }, "0"], ]); diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 21b73010..c57f80d3 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -58,9 +58,27 @@ export interface Email { // S/MIME support blobId?: string; bodyStructure?: EmailBodyPart; - // Unified mailbox support - set when displaying emails from multiple accounts + // Unified mailbox support - set when displaying emails from multiple accounts. + // `accountId` is a DISPLAY-only reference (avatar color / label / badge) and may + // hold either an AccountEntry.id (personal) or the JMAP owner id (shared). For + // resolving the client + JMAP routing use the two dedicated fields below, which + // are always set on aggregated emails and unambiguous. accountId?: string; accountLabel?: string; + // AccountEntry.id of the logged-in client through which this email is reachable. + // Always a real login key → `useAuthStore.getClientForAccount(...)` resolves it. + // For personal sources this equals the account itself; for shared/group sources + // it is the delegating login (the shared account has no own login). + sourceClientAccountId?: string; + // JMAP account id of the email's owning account (personal: the client's primary; + // shared/group: the owner account). Always safe to pass as the JMAP `accountId` + // argument — equal to the client's primary for personal sources, so it is a no-op + // there, and triggers owner-scoped routing + mailbox-id namespacing for shared. + sourceAccountId?: string; + // Name of the email's originating folder, stamped for the aggregate "All …" + // views (All Mail, unified, cross-account) so the list can show where each + // message lives. Transient/client-only, not part of the JMAP object. + sourceFolder?: string; // Client-only scheduled-send metadata, populated from EmailSubmission/query. scheduledSendAt?: string; emailSubmissionId?: string; @@ -879,3 +897,38 @@ export function isUnifiedMailboxId(id: string): boolean { * included is a per-user setting (see `allMailFolderIds`). */ export const ALL_MAIL_MAILBOX_ID = '__all_mail__'; + +/** + * Cross-account "All …" views shown in the unified ("All accounts") section. + * Each merges messages across EVERY account (including shared/group folders), + * spanning all folders except junk/spam, sent, archive, trash and drafts, in + * one date-sorted list. Distinct from the per-role unified ids (one role across + * accounts) and from ALL_MAIL_MAILBOX_ID (all folders of a single account). + */ +export const CROSS_UNREAD = '__cross_unread__'; +export const CROSS_STARRED = '__cross_starred__'; +export const CROSS_ALL = '__cross_all__'; + +export type CrossView = 'unread' | 'starred' | 'all'; + +export const CROSS_VIEW_IDS: Record = { + unread: CROSS_UNREAD, + starred: CROSS_STARRED, + all: CROSS_ALL, +}; + +export const CROSS_VIEW_BY_ID: Record = Object.fromEntries( + Object.entries(CROSS_VIEW_IDS).map(([view, id]) => [id, view as CrossView]) +) as Record; + +export function isCrossViewId(id: string): boolean { + return id in CROSS_VIEW_BY_ID; +} + +/** + * Mailbox roles excluded from the cross-account views. Everything else (inbox + * and custom/no-role folders) is included. + */ +export const CROSS_EXCLUDED_ROLES: ReadonlySet = new Set([ + 'junk', 'sent', 'archive', 'trash', 'drafts', +]); diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 1b3a1f8e..9bd0c11d 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -1,11 +1,22 @@ -import type { Email, Mailbox, UnifiedMailboxRole } from '@/lib/jmap/types'; +import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types'; +import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; export interface UnifiedAccountClient { + // Display reference (avatar color / label). For personal entries this is the + // AccountEntry.id; for shared entries it is the JMAP owner id (see Email.accountId). accountId: string; accountLabel: string; client: IJMAPClient; mailboxes: Mailbox[]; + // AccountEntry.id of the logged-in client this entry uses (`getClientForAccount` + // key). Stamped onto each email as `sourceClientAccountId` so single-email and + // batch actions can resolve the reaching client without scanning capabilities. + clientAccountId: string; + // JMAP account id of the data this entry reads (personal: the client's primary; + // shared: the owner id). Stamped onto each email as `sourceAccountId` and passed + // as the JMAP `accountId` for owner-scoped routing + mailbox-id namespacing. + jmapAccountId: string; // When true, this entry represents a group/shared account owned by // `accountId` but accessed through someone else's `client`. JMAP requests // must use the mailbox's `originalId` and explicitly target this accountId @@ -30,6 +41,19 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [ 'inbox', 'sent', 'drafts', 'trash', 'archive', 'junk', ]; +/** + * Resolves the display name of the folder an email lives in, for the aggregate + * "All …" views. Matches the email's mailbox membership against the account's + * mailbox list (originalId for shared/namespaced mailboxes). Returns the first + * match, or undefined if none of the account's known folders contain it. + */ +export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined { + for (const m of mailboxes) { + if (email.mailboxIds?.[m.originalId ?? m.id]) return m.name; + } + return undefined; +} + /** * Finds the first mailbox matching the given role. */ @@ -99,6 +123,9 @@ export async function fetchUnifiedEmails( for (const email of result.emails) { email.accountId = account.accountId; email.accountLabel = account.accountLabel; + email.sourceClientAccountId = account.clientAccountId; + email.sourceAccountId = account.jmapAccountId; + email.sourceFolder = resolveSourceFolderName(email, account.mailboxes); } mergedEmails = mergedEmails.concat(result.emails); @@ -223,6 +250,9 @@ async function fanOutUnifiedQuery( for (const email of result.emails) { email.accountId = account.accountId; email.accountLabel = account.accountLabel; + email.sourceClientAccountId = account.clientAccountId; + email.sourceAccountId = account.jmapAccountId; + email.sourceFolder = resolveSourceFolderName(email, account.mailboxes); } mergedEmails = mergedEmails.concat(result.emails); totalSum += result.total; @@ -269,6 +299,145 @@ export function fetchUnifiedMailboxCounts( return counts; } +// ─── Cross-account views (unread / starred / all) ───────────────────────────── +// +// These merge messages across EVERY account (including shared) and across all +// folders except the CROSS_EXCLUDED_ROLES (junk, sent, archive, trash, drafts), +// i.e. inbox + custom folders, into one date-sorted list. Unlike the per-role +// unified fan-out above, the query spans many mailboxes per account, so the +// filter is built from each account's included-mailbox ids. + +/** + * Mailboxes of an account included in the cross-account views: everything whose + * role is not excluded (inbox + custom/no-role folders). + */ +export function getCrossIncludedMailboxes(account: UnifiedAccountClient): Mailbox[] { + return account.mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? '')); +} + +/** + * Builds the JMAP Email/query filter for a cross-account view over the given + * JMAP-side mailbox ids. `all` is just the mailbox membership; `unread` and + * `starred` AND a keyword condition onto it. + */ +export function buildCrossFilter( + view: CrossView, + jmapMailboxIds: string[], +): Record { + const inAny: Record = jmapMailboxIds.length === 1 + ? { inMailbox: jmapMailboxIds[0] } + : { operator: 'OR', conditions: jmapMailboxIds.map((id) => ({ inMailbox: id })) }; + if (view === 'all') return inAny; + const keyword = view === 'unread' ? { notKeyword: '$seen' } : { hasKeyword: '$flagged' }; + return { operator: 'AND', conditions: [inAny, keyword] }; +} + +/** + * Total unread count across every account's included cross-view mailboxes. Used + * for the unread badge on the "All unread" and "All mail" entries. Mirrors the + * unified count behaviour (sum of per-mailbox unread metadata, no extra query). + */ +export function getCrossUnreadTotal(accounts: UnifiedAccountClient[]): number { + let unread = 0; + for (const account of accounts) { + for (const m of getCrossIncludedMailboxes(account)) unread += m.unreadEmails; + } + return unread; +} + +async function fanOutCrossQuery( + accounts: UnifiedAccountClient[], + run: ( + account: UnifiedAccountClient, + jmapAccountId: string | undefined, + includedJmapIds: string[], + ) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>, +): Promise { + const errors = new Map(); + + type AccountResult = { + account: UnifiedAccountClient; + result: { emails: Email[]; total: number; hasMore: boolean }; + } | null; + + const promises = accounts.map(async (account): Promise => { + const included = getCrossIncludedMailboxes(account); + if (included.length === 0) return null; + const jmapAccountId = account.isShared ? account.accountId : undefined; + const includedJmapIds = included.map((m) => account.isShared ? (m.originalId ?? m.id) : m.id); + try { + const result = await run(account, jmapAccountId, includedJmapIds); + return { account, result }; + } catch (err) { + errors.set(account.accountId, err instanceof Error ? err.message : String(err)); + return null; + } + }); + + const results = await Promise.allSettled(promises); + + let mergedEmails: Email[] = []; + let totalSum = 0; + let anyHasMore = false; + + for (const outcome of results) { + if (outcome.status !== 'fulfilled' || outcome.value === null) continue; + const { account, result } = outcome.value; + for (const email of result.emails) { + email.accountId = account.accountId; + email.accountLabel = account.accountLabel; + email.sourceClientAccountId = account.clientAccountId; + email.sourceAccountId = account.jmapAccountId; + email.sourceFolder = resolveSourceFolderName(email, account.mailboxes); + } + mergedEmails = mergedEmails.concat(result.emails); + totalSum += result.total; + if (result.hasMore) anyHasMore = true; + } + + mergedEmails.sort((a, b) => { + const dateA = new Date(a.receivedAt).getTime(); + const dateB = new Date(b.receivedAt).getTime(); + return dateB - dateA; + }); + + return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors }; +} + +/** + * Fetches a cross-account view (browse), merging and date-sorting across all + * accounts. Per-account failures are collected in the errors map. + */ +export async function fetchCrossViewEmails( + accounts: UnifiedAccountClient[], + view: CrossView, + limit: number, + position: number, +): Promise { + return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => + account.client.advancedSearchEmails(buildCrossFilter(view, ids), jmapAccountId, limit, position)); +} + +/** + * Text search within a cross-account view: the view filter AND a free-text + * condition, fanned out across accounts. + */ +export async function searchCrossViewEmails( + accounts: UnifiedAccountClient[], + view: CrossView, + query: string, + limit: number, + position: number, +): Promise { + return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => + account.client.advancedSearchEmails( + { operator: 'AND', conditions: [buildCrossFilter(view, ids), { text: query }] }, + jmapAccountId, + limit, + position, + )); +} + /** * Returns the list of unified roles that exist in at least one account's * mailboxes. diff --git a/locales/cs/common.json b/locales/cs/common.json index 01ca20f9..9244eb69 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -134,7 +134,10 @@ "mail": "Pošta", "nav_label": "Navigace", "add_app": "Aplikace", - "scheduled": "Naplánováno" + "scheduled": "Naplánováno", + "unified_all_unread": "Vše nepřečtené", + "unified_all_starred": "Vše s hvězdičkou", + "unified_all_mail": "Veškerá pošta" }, "protocol_handlers": { "title": "Výchozí aplikace", @@ -930,6 +933,18 @@ "description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.", "open_label": "Otevřít Pro rozhraní", "back_to_standard": "Zpět na standard" + }, + "cross_unread": { + "label": "Všechny účty: Nepřečtené", + "description": "Zobrazí v sekci Všechny účty položku s nepřečtenou poštou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů." + }, + "cross_starred": { + "label": "Všechny účty: S hvězdičkou", + "description": "Zobrazí v sekci Všechny účty položku s poštou s hvězdičkou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů." + }, + "cross_all": { + "label": "Všechny účty: Veškerá pošta", + "description": "Zobrazí v sekci Všechny účty položku s veškerou poštou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Archivovat", "tag": "Štítek", "spam": "Označit jako spam", + "not_spam": "Není spam", "none_selected": "Nebyly vybrány žádné akce", "mode_label": "Režim zobrazení", "mode_inline": "Vložené", diff --git a/locales/da/common.json b/locales/da/common.json index 4365f880..143066f4 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -134,7 +134,10 @@ "mail": "Mail", "nav_label": "Navigation", "add_app": "Apps", - "scheduled": "Planlagt" + "scheduled": "Planlagt", + "unified_all_unread": "Alle ulæste", + "unified_all_starred": "Alle med stjerne", + "unified_all_mail": "Al post" }, "protocol_handlers": { "title": "Standardapps", @@ -933,6 +936,18 @@ "description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.", "open_label": "Åbn Pro-grænsefladen", "back_to_standard": "Tilbage til standard" + }, + "cross_unread": { + "label": "Alle konti: Ulæste", + "description": "Viser en post i sektionen Alle konti med ulæst post fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder." + }, + "cross_starred": { + "label": "Alle konti: Med stjerne", + "description": "Viser en post i sektionen Alle konti med post med stjerne fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder." + }, + "cross_all": { + "label": "Alle konti: Al post", + "description": "Viser en post i sektionen Alle konti med al post fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder." } }, "keywords": { @@ -1189,6 +1204,7 @@ "archive": "Arkivér", "tag": "Tag", "spam": "Markér som spam", + "not_spam": "Ikke spam", "none_selected": "Ingen handlinger valgt", "mode_label": "Visningstilstand", "mode_inline": "Indlejret", diff --git a/locales/de/common.json b/locales/de/common.json index a0f81efc..b53b64d4 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -134,7 +134,10 @@ "nav_label": "Navigation", "add_app": "Apps", "shared": "Geteilt", - "scheduled": "Geplant" + "scheduled": "Geplant", + "unified_all_unread": "Alle ungelesenen", + "unified_all_starred": "Alle markierten", + "unified_all_mail": "Alle Mails" }, "protocol_handlers": { "title": "Standard-Apps", @@ -930,6 +933,18 @@ "description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.", "open_label": "Pro-Oberfläche öffnen", "back_to_standard": "Zurück zum Standard" + }, + "cross_unread": { + "label": "Alle Konten: Ungelesen", + "description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit ungelesenen Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe." + }, + "cross_starred": { + "label": "Alle Konten: Markiert", + "description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit markierten Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe." + }, + "cross_all": { + "label": "Alle Konten: Alle Mails", + "description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit allen Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Archivieren", "tag": "Label", "spam": "Als Spam markieren", + "not_spam": "Kein Spam", "none_selected": "Keine Aktionen ausgewählt", "mode_label": "Anzeigemodus", "mode_inline": "Eingebettet", diff --git a/locales/en/common.json b/locales/en/common.json index ca1d0f8d..e77f6a19 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -134,7 +134,10 @@ "mail": "Mail", "nav_label": "Navigation", "add_app": "Apps", - "scheduled": "Scheduled" + "scheduled": "Scheduled", + "unified_all_unread": "All unread", + "unified_all_starred": "All starred", + "unified_all_mail": "All mail" }, "protocol_handlers": { "title": "Default apps", @@ -933,6 +936,18 @@ "description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.", "open_label": "Open Pro Interface", "back_to_standard": "Back to standard" + }, + "cross_unread": { + "label": "All accounts: Unread", + "description": "Show an entry in the All accounts section listing unread mail across every account, spanning all folders except junk, sent, archive, trash and drafts." + }, + "cross_starred": { + "label": "All accounts: Starred", + "description": "Show an entry in the All accounts section listing starred mail across every account, spanning all folders except junk, sent, archive, trash and drafts." + }, + "cross_all": { + "label": "All accounts: All mail", + "description": "Show an entry in the All accounts section listing all mail across every account, spanning all folders except junk, sent, archive, trash and drafts." } }, "keywords": { @@ -1189,6 +1204,7 @@ "archive": "Archive", "tag": "Tag", "spam": "Mark as Spam", + "not_spam": "Not Spam", "none_selected": "No actions selected", "mode_label": "Display Mode", "mode_inline": "Inline", diff --git a/locales/es/common.json b/locales/es/common.json index b88ffa1c..ef462458 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -134,7 +134,10 @@ "nav_label": "Navegación", "add_app": "Apps", "shared": "Compartido", - "scheduled": "Programados" + "scheduled": "Programados", + "unified_all_unread": "Todo no leído", + "unified_all_starred": "Todo destacado", + "unified_all_mail": "Todo el correo" }, "protocol_handlers": { "title": "Aplicaciones predeterminadas", @@ -930,6 +933,18 @@ "description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.", "open_label": "Abrir interfaz Pro", "back_to_standard": "Volver al estándar" + }, + "cross_unread": { + "label": "Todas las cuentas: No leído", + "description": "Muestra una entrada en la sección Todas las cuentas con el correo no leído de todas las cuentas, abarcando todas las carpetas excepto spam, enviados, archivo, papelera y borradores." + }, + "cross_starred": { + "label": "Todas las cuentas: Destacado", + "description": "Muestra una entrada en la sección Todas las cuentas con el correo destacado de todas las cuentas, abarcando todas las carpetas excepto spam, enviados, archivo, papelera y borradores." + }, + "cross_all": { + "label": "Todas las cuentas: Todo el correo", + "description": "Muestra una entrada en la sección Todas las cuentas con todo el correo de todas las cuentas, abarcando todas las carpetas excepto spam, enviados, archivo, papelera y borradores." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Archivar", "tag": "Etiqueta", "spam": "Marcar como spam", + "not_spam": "No es spam", "none_selected": "No hay acciones seleccionadas", "mode_label": "Modo de visualización", "mode_inline": "En línea", diff --git a/locales/fr/common.json b/locales/fr/common.json index 95c74a1e..ca0b6904 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -134,7 +134,10 @@ "nav_label": "Navigation", "add_app": "Apps", "shared": "Partagé", - "scheduled": "Planifiés" + "scheduled": "Planifiés", + "unified_all_unread": "Tous les non lus", + "unified_all_starred": "Tous les favoris", + "unified_all_mail": "Tout le courrier" }, "protocol_handlers": { "title": "Applications par défaut", @@ -930,6 +933,18 @@ "description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.", "open_label": "Ouvrir l'interface Pro", "back_to_standard": "Retour au standard" + }, + "cross_unread": { + "label": "Tous les comptes : Non lus", + "description": "Affiche une entrée dans la section Tous les comptes répertoriant les messages non lus de tous les comptes, couvrant tous les dossiers sauf indésirables, envoyés, archives, corbeille et brouillons." + }, + "cross_starred": { + "label": "Tous les comptes : Favoris", + "description": "Affiche une entrée dans la section Tous les comptes répertoriant les messages favoris de tous les comptes, couvrant tous les dossiers sauf indésirables, envoyés, archives, corbeille et brouillons." + }, + "cross_all": { + "label": "Tous les comptes : Tout le courrier", + "description": "Affiche une entrée dans la section Tous les comptes répertoriant tout le courrier de tous les comptes, couvrant tous les dossiers sauf indésirables, envoyés, archives, corbeille et brouillons." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Archiver", "tag": "Étiquette", "spam": "Marquer comme spam", + "not_spam": "Non indésirable", "none_selected": "Aucune action sélectionnée", "mode_label": "Mode d'affichage", "mode_inline": "En ligne", diff --git a/locales/hu/common.json b/locales/hu/common.json index 1a5aed9f..fa478aa6 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -134,7 +134,10 @@ "mail": "Levelek", "nav_label": "Navigáció", "add_app": "Alkalmazások", - "scheduled": "Ütemezett" + "scheduled": "Ütemezett", + "unified_all_unread": "Összes olvasatlan", + "unified_all_starred": "Összes csillagozott", + "unified_all_mail": "Összes levél" }, "protocol_handlers": { "title": "Alapértelmezett alkalmazások", @@ -933,6 +936,18 @@ "description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.", "open_label": "Pro felület megnyitása", "back_to_standard": "Vissza a szabványoshoz" + }, + "cross_unread": { + "label": "Összes fiók: Olvasatlan", + "description": "Megjelenít egy bejegyzést az Összes fiók szakaszban, amely az összes fiók olvasatlan leveleit listázza, a levélszemét, elküldött, archívum, kuka és piszkozatok mappák kivételével minden mappára kiterjedően." + }, + "cross_starred": { + "label": "Összes fiók: Csillagozott", + "description": "Megjelenít egy bejegyzést az Összes fiók szakaszban, amely az összes fiók csillagozott leveleit listázza, a levélszemét, elküldött, archívum, kuka és piszkozatok mappák kivételével minden mappára kiterjedően." + }, + "cross_all": { + "label": "Összes fiók: Összes levél", + "description": "Megjelenít egy bejegyzést az Összes fiók szakaszban, amely az összes fiók összes levelét listázza, a levélszemét, elküldött, archívum, kuka és piszkozatok mappák kivételével minden mappára kiterjedően." } }, "keywords": { @@ -1189,6 +1204,7 @@ "archive": "Archiválás", "tag": "Címkézés", "spam": "Megjelölés spamként", + "not_spam": "Nem spam", "none_selected": "Nincs művelet kiválasztva", "mode_label": "Megjelenítési mód", "mode_inline": "Beágyazott", diff --git a/locales/it/common.json b/locales/it/common.json index 0566a8a0..e8239822 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -134,7 +134,10 @@ "nav_label": "Navigazione", "add_app": "App", "shared": "Condiviso", - "scheduled": "Programmate" + "scheduled": "Programmate", + "unified_all_unread": "Tutti i non letti", + "unified_all_starred": "Tutti gli speciali", + "unified_all_mail": "Tutta la posta" }, "protocol_handlers": { "title": "App predefinite", @@ -930,6 +933,18 @@ "description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.", "open_label": "Apri interfaccia Pro", "back_to_standard": "Torna allo standard" + }, + "cross_unread": { + "label": "Tutti gli account: Non letti", + "description": "Mostra una voce nella sezione Tutti gli account che elenca la posta non letta di tutti gli account, in tutte le cartelle tranne posta indesiderata, inviata, archivio, cestino e bozze." + }, + "cross_starred": { + "label": "Tutti gli account: Speciali", + "description": "Mostra una voce nella sezione Tutti gli account che elenca la posta speciale di tutti gli account, in tutte le cartelle tranne posta indesiderata, inviata, archivio, cestino e bozze." + }, + "cross_all": { + "label": "Tutti gli account: Tutta la posta", + "description": "Mostra una voce nella sezione Tutti gli account che elenca tutta la posta di tutti gli account, in tutte le cartelle tranne posta indesiderata, inviata, archivio, cestino e bozze." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Archivia", "tag": "Etichetta", "spam": "Segna come spam", + "not_spam": "Non spam", "none_selected": "Nessuna azione selezionata", "mode_label": "Modalità di visualizzazione", "mode_inline": "In linea", diff --git a/locales/ja/common.json b/locales/ja/common.json index 91c67b66..78a74926 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -134,7 +134,10 @@ "nav_label": "ナビゲーション", "add_app": "アプリ", "shared": "共有", - "scheduled": "予約済み" + "scheduled": "予約済み", + "unified_all_unread": "すべての未読", + "unified_all_starred": "すべてのスター付き", + "unified_all_mail": "すべてのメール" }, "protocol_handlers": { "title": "既定のアプリ", @@ -930,6 +933,18 @@ "description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。", "open_label": "Pro インターフェイスを開く", "back_to_standard": "標準に戻る" + }, + "cross_unread": { + "label": "すべてのアカウント: 未読", + "description": "「すべてのアカウント」セクションに、すべてのアカウントの未読メールを一覧表示する項目を表示します。迷惑メール、送信済み、アーカイブ、ゴミ箱、下書きを除くすべてのフォルダーが対象です。" + }, + "cross_starred": { + "label": "すべてのアカウント: スター付き", + "description": "「すべてのアカウント」セクションに、すべてのアカウントのスター付きメールを一覧表示する項目を表示します。迷惑メール、送信済み、アーカイブ、ゴミ箱、下書きを除くすべてのフォルダーが対象です。" + }, + "cross_all": { + "label": "すべてのアカウント: すべてのメール", + "description": "「すべてのアカウント」セクションに、すべてのアカウントのすべてのメールを一覧表示する項目を表示します。迷惑メール、送信済み、アーカイブ、ゴミ箱、下書きを除くすべてのフォルダーが対象です。" } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "アーカイブ", "tag": "ラベル", "spam": "スパムとしてマーク", + "not_spam": "迷惑メールではない", "none_selected": "アクションが選択されていません", "mode_label": "表示モード", "mode_inline": "インライン", diff --git a/locales/ko/common.json b/locales/ko/common.json index bbe92263..302aba24 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -134,7 +134,10 @@ "nav_label": "내비게이션", "add_app": "앱", "shared": "공유됨", - "scheduled": "예약됨" + "scheduled": "예약됨", + "unified_all_unread": "모든 읽지 않음", + "unified_all_starred": "모든 별표", + "unified_all_mail": "모든 메일" }, "protocol_handlers": { "title": "기본 앱", @@ -930,6 +933,18 @@ "description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.", "open_label": "Pro 인터페이스 열기", "back_to_standard": "표준으로 돌아가기" + }, + "cross_unread": { + "label": "모든 계정: 읽지 않음", + "description": "모든 계정 섹션에 모든 계정의 읽지 않은 메일을 나열하는 항목을 표시합니다. 스팸, 보낸편지함, 보관함, 휴지통, 임시보관함을 제외한 모든 폴더가 대상입니다." + }, + "cross_starred": { + "label": "모든 계정: 별표", + "description": "모든 계정 섹션에 모든 계정의 별표 메일을 나열하는 항목을 표시합니다. 스팸, 보낸편지함, 보관함, 휴지통, 임시보관함을 제외한 모든 폴더가 대상입니다." + }, + "cross_all": { + "label": "모든 계정: 모든 메일", + "description": "모든 계정 섹션에 모든 계정의 모든 메일을 나열하는 항목을 표시합니다. 스팸, 보낸편지함, 보관함, 휴지통, 임시보관함을 제외한 모든 폴더가 대상입니다." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "보관", "tag": "태그", "spam": "스팸으로 표시", + "not_spam": "스팸 아님", "none_selected": "선택된 액션 없음", "mode_label": "표시 모드", "mode_inline": "목록 안쪽", diff --git a/locales/lv/common.json b/locales/lv/common.json index be64fde0..a87ae65f 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -134,7 +134,10 @@ "nav_label": "Navigācija", "add_app": "Lietotnes", "shared": "Koplietots", - "scheduled": "Ieplānots" + "scheduled": "Ieplānots", + "unified_all_unread": "Visi nelasītie", + "unified_all_starred": "Visi ar zvaigzni", + "unified_all_mail": "Visas vēstules" }, "protocol_handlers": { "title": "Noklusējuma lietotnes", @@ -930,6 +933,18 @@ "description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.", "open_label": "Atvērt Pro saskarni", "back_to_standard": "Atpakaļ uz standartu" + }, + "cross_unread": { + "label": "Visi konti: Nelasītie", + "description": "Rāda ierakstu sadaļā “Visi konti”, kas uzskaita nelasītās vēstules no visiem kontiem, aptverot visas mapes, izņemot mēstules, nosūtītās, arhīvu, miskasti un melnrakstus." + }, + "cross_starred": { + "label": "Visi konti: Ar zvaigzni", + "description": "Rāda ierakstu sadaļā “Visi konti”, kas uzskaita ar zvaigzni atzīmētās vēstules no visiem kontiem, aptverot visas mapes, izņemot mēstules, nosūtītās, arhīvu, miskasti un melnrakstus." + }, + "cross_all": { + "label": "Visi konti: Visas vēstules", + "description": "Rāda ierakstu sadaļā “Visi konti”, kas uzskaita visas vēstules no visiem kontiem, aptverot visas mapes, izņemot mēstules, nosūtītās, arhīvu, miskasti un melnrakstus." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Arhivēt", "tag": "Tags", "spam": "Mēstule", + "not_spam": "Nav mēstule", "none_selected": "Nav atlasītu darbību", "mode_label": "Attēlošanas režīms", "mode_inline": "Iebūvēts", diff --git a/locales/nl/common.json b/locales/nl/common.json index b05bba1d..399163a2 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -134,7 +134,10 @@ "nav_label": "Navigatie", "add_app": "Apps", "shared": "Gedeeld", - "scheduled": "Gepland" + "scheduled": "Gepland", + "unified_all_unread": "Alle ongelezen", + "unified_all_starred": "Alle met ster", + "unified_all_mail": "Alle e-mail" }, "protocol_handlers": { "title": "Standaardapps", @@ -930,6 +933,18 @@ "description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.", "open_label": "Pro-interface openen", "back_to_standard": "Terug naar standaard" + }, + "cross_unread": { + "label": "Alle accounts: Ongelezen", + "description": "Toont een item in de sectie Alle accounts met ongelezen e-mail van alle accounts, in alle mappen behalve ongewenst, verzonden, archief, prullenbak en concepten." + }, + "cross_starred": { + "label": "Alle accounts: Met ster", + "description": "Toont een item in de sectie Alle accounts met e-mail met ster van alle accounts, in alle mappen behalve ongewenst, verzonden, archief, prullenbak en concepten." + }, + "cross_all": { + "label": "Alle accounts: Alle e-mail", + "description": "Toont een item in de sectie Alle accounts met alle e-mail van alle accounts, in alle mappen behalve ongewenst, verzonden, archief, prullenbak en concepten." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Archiveren", "tag": "Label", "spam": "Markeer als spam", + "not_spam": "Geen spam", "none_selected": "Geen acties geselecteerd", "mode_label": "Weergavemodus", "mode_inline": "Inline", diff --git a/locales/pl/common.json b/locales/pl/common.json index 6c18886b..09154a32 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -134,7 +134,10 @@ "nav_label": "Nawigacja", "add_app": "Aplikacje", "shared": "Udostępnione", - "scheduled": "Zaplanowane" + "scheduled": "Zaplanowane", + "unified_all_unread": "Wszystkie nieprzeczytane", + "unified_all_starred": "Wszystkie oznaczone gwiazdką", + "unified_all_mail": "Cała poczta" }, "protocol_handlers": { "title": "Aplikacje domyślne", @@ -930,6 +933,18 @@ "description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.", "open_label": "Otwórz interfejs Pro", "back_to_standard": "Powrót do standardu" + }, + "cross_unread": { + "label": "Wszystkie konta: Nieprzeczytane", + "description": "Pokazuje pozycję w sekcji Wszystkie konta z nieprzeczytaną pocztą ze wszystkich kont, obejmującą wszystkie foldery oprócz spamu, wysłanych, archiwum, kosza i roboczych." + }, + "cross_starred": { + "label": "Wszystkie konta: Oznaczone gwiazdką", + "description": "Pokazuje pozycję w sekcji Wszystkie konta z pocztą oznaczoną gwiazdką ze wszystkich kont, obejmującą wszystkie foldery oprócz spamu, wysłanych, archiwum, kosza i roboczych." + }, + "cross_all": { + "label": "Wszystkie konta: Cała poczta", + "description": "Pokazuje pozycję w sekcji Wszystkie konta z całą pocztą ze wszystkich kont, obejmującą wszystkie foldery oprócz spamu, wysłanych, archiwum, kosza i roboczych." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Archiwizuj", "tag": "Etykieta", "spam": "Oznacz jako spam", + "not_spam": "Nie spam", "none_selected": "Nie wybrano żadnych akcji", "mode_label": "Tryb wyświetlania", "mode_inline": "Wbudowany", diff --git a/locales/pt/common.json b/locales/pt/common.json index 1245e271..da68af86 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -134,7 +134,10 @@ "nav_label": "Navegação", "add_app": "Apps", "shared": "Compartilhado", - "scheduled": "Agendados" + "scheduled": "Agendados", + "unified_all_unread": "Tudo não lido", + "unified_all_starred": "Tudo com estrela", + "unified_all_mail": "Todo o correio" }, "protocol_handlers": { "title": "Aplicativos padrão", @@ -930,6 +933,18 @@ "description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.", "open_label": "Abrir interface Pro", "back_to_standard": "Voltar ao padrão" + }, + "cross_unread": { + "label": "Todas as contas: Não lido", + "description": "Mostra uma entrada na seção Todas as contas listando o correio não lido de todas as contas, abrangendo todas as pastas exceto spam, enviados, arquivo, lixeira e rascunhos." + }, + "cross_starred": { + "label": "Todas as contas: Com estrela", + "description": "Mostra uma entrada na seção Todas as contas listando o correio com estrela de todas as contas, abrangendo todas as pastas exceto spam, enviados, arquivo, lixeira e rascunhos." + }, + "cross_all": { + "label": "Todas as contas: Todo o correio", + "description": "Mostra uma entrada na seção Todas as contas listando todo o correio de todas as contas, abrangendo todas as pastas exceto spam, enviados, arquivo, lixeira e rascunhos." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Arquivar", "tag": "Etiqueta", "spam": "Marcar como spam", + "not_spam": "Não é spam", "none_selected": "Nenhuma ação selecionada", "mode_label": "Modo de exibição", "mode_inline": "Em linha", diff --git a/locales/ro/common.json b/locales/ro/common.json index aa3c0450..f6817394 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -134,7 +134,10 @@ "mail": "E-mail", "nav_label": "Navigare", "add_app": "Aplicații", - "scheduled": "Programat" + "scheduled": "Programat", + "unified_all_unread": "Toate necitite", + "unified_all_starred": "Toate cu stea", + "unified_all_mail": "Toată poșta" }, "protocol_handlers": { "title": "Aplicații implicite", @@ -933,6 +936,18 @@ "description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.", "open_label": "Interfața Open Pro", "back_to_standard": "Înapoi la standard" + }, + "cross_unread": { + "label": "Toate conturile: Necitite", + "description": "Afișează în secțiunea Toate conturile o intrare cu poșta necitită din toate conturile, în toate folderele cu excepția spam, trimise, arhivă, coș și ciorne." + }, + "cross_starred": { + "label": "Toate conturile: Cu stea", + "description": "Afișează în secțiunea Toate conturile o intrare cu poșta cu stea din toate conturile, în toate folderele cu excepția spam, trimise, arhivă, coș și ciorne." + }, + "cross_all": { + "label": "Toate conturile: Toată poșta", + "description": "Afișează în secțiunea Toate conturile o intrare cu toată poșta din toate conturile, în toate folderele cu excepția spam, trimise, arhivă, coș și ciorne." } }, "keywords": { @@ -1189,6 +1204,7 @@ "archive": "Arhivează", "tag": "Etichetă", "spam": "Marcați ca spam", + "not_spam": "Nu este spam", "none_selected": "Nu sunt selectate acțiuni", "mode_label": "Modul de afișare", "mode_inline": "În text", diff --git a/locales/ru/common.json b/locales/ru/common.json index 8adc1710..8e190757 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -134,7 +134,10 @@ "nav_label": "Навигация", "add_app": "Приложения", "shared": "Общие", - "scheduled": "Запланировано" + "scheduled": "Запланировано", + "unified_all_unread": "Все непрочитанные", + "unified_all_starred": "Все помеченные", + "unified_all_mail": "Вся почта" }, "protocol_handlers": { "title": "Приложения по умолчанию", @@ -930,6 +933,18 @@ "description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.", "open_label": "Открыть Pro-интерфейс", "back_to_standard": "К стандартному" + }, + "cross_unread": { + "label": "Все аккаунты: Непрочитанные", + "description": "Показывает в разделе «Все аккаунты» запись с непрочитанными письмами из всех аккаунтов, по всем папкам, кроме спама, отправленных, архива, корзины и черновиков." + }, + "cross_starred": { + "label": "Все аккаунты: Помеченные", + "description": "Показывает в разделе «Все аккаунты» запись с помеченными письмами из всех аккаунтов, по всем папкам, кроме спама, отправленных, архива, корзины и черновиков." + }, + "cross_all": { + "label": "Все аккаунты: Вся почта", + "description": "Показывает в разделе «Все аккаунты» запись со всеми письмами из всех аккаунтов, по всем папкам, кроме спама, отправленных, архива, корзины и черновиков." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Архивировать", "tag": "Тег", "spam": "Отметить как спам", + "not_spam": "Не спам", "none_selected": "Действия не выбраны", "mode_label": "Режим отображения", "mode_inline": "Встроенный", diff --git a/locales/tr/common.json b/locales/tr/common.json index 7b312ee0..c4d55e91 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -134,7 +134,10 @@ "mail": "Posta", "nav_label": "Gezinme", "add_app": "Uygulamalar", - "scheduled": "Zamanlandı" + "scheduled": "Zamanlandı", + "unified_all_unread": "Tüm okunmamışlar", + "unified_all_starred": "Tüm yıldızlılar", + "unified_all_mail": "Tüm postalar" }, "protocol_handlers": { "title": "Varsayılan uygulamalar", @@ -930,6 +933,18 @@ "description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.", "open_label": "Pro Arayüzü Aç", "back_to_standard": "Standarda dön" + }, + "cross_unread": { + "label": "Tüm hesaplar: Okunmamış", + "description": "Tüm hesaplar bölümünde, tüm hesaplardaki okunmamış postaları listeleyen bir giriş gösterir; önemsiz, gönderilmiş, arşiv, çöp ve taslaklar dışındaki tüm klasörleri kapsar." + }, + "cross_starred": { + "label": "Tüm hesaplar: Yıldızlı", + "description": "Tüm hesaplar bölümünde, tüm hesaplardaki yıldızlı postaları listeleyen bir giriş gösterir; önemsiz, gönderilmiş, arşiv, çöp ve taslaklar dışındaki tüm klasörleri kapsar." + }, + "cross_all": { + "label": "Tüm hesaplar: Tüm postalar", + "description": "Tüm hesaplar bölümünde, tüm hesaplardaki tüm postaları listeleyen bir giriş gösterir; önemsiz, gönderilmiş, arşiv, çöp ve taslaklar dışındaki tüm klasörleri kapsar." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Arşivle", "tag": "Etiketle", "spam": "Spam Olarak İşaretle", + "not_spam": "Önemsiz değil", "none_selected": "Hiç işlem seçilmedi", "mode_label": "Görüntüleme Modu", "mode_inline": "Satır içi", diff --git a/locales/uk/common.json b/locales/uk/common.json index ab746338..d5e18dd9 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -134,7 +134,10 @@ "nav_label": "Навігація", "add_app": "програми", "shared": "Спільні", - "scheduled": "Заплановано" + "scheduled": "Заплановано", + "unified_all_unread": "Усі непрочитані", + "unified_all_starred": "Усі позначені", + "unified_all_mail": "Уся пошта" }, "protocol_handlers": { "title": "Програми за замовчуванням", @@ -930,6 +933,18 @@ "description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.", "open_label": "Відкрити Pro-інтерфейс", "back_to_standard": "Назад до стандарту" + }, + "cross_unread": { + "label": "Усі облікові записи: Непрочитані", + "description": "Показує у розділі «Усі облікові записи» запис із непрочитаними листами з усіх облікових записів, по всіх теках, окрім спаму, надісланих, архіву, кошика та чернеток." + }, + "cross_starred": { + "label": "Усі облікові записи: Позначені", + "description": "Показує у розділі «Усі облікові записи» запис із позначеними листами з усіх облікових записів, по всіх теках, окрім спаму, надісланих, архіву, кошика та чернеток." + }, + "cross_all": { + "label": "Усі облікові записи: Уся пошта", + "description": "Показує у розділі «Усі облікові записи» запис з усіма листами з усіх облікових записів, по всіх теках, окрім спаму, надісланих, архіву, кошика та чернеток." } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "Архів", "tag": "Тег", "spam": "Позначити як спам", + "not_spam": "Не спам", "none_selected": "Дії не вибрано", "mode_label": "Режим відображення", "mode_inline": "Вбудований", diff --git a/locales/zh/common.json b/locales/zh/common.json index fc6228d7..7bb1e41e 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -134,7 +134,10 @@ "nav_label": "导航", "add_app": "应用", "shared": "共享", - "scheduled": "已计划" + "scheduled": "已计划", + "unified_all_unread": "全部未读", + "unified_all_starred": "全部加星标", + "unified_all_mail": "全部邮件" }, "protocol_handlers": { "title": "默认应用", @@ -930,6 +933,18 @@ "description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。", "open_label": "打开 Pro 界面", "back_to_standard": "返回标准" + }, + "cross_unread": { + "label": "所有账户:未读", + "description": "在“所有账户”部分显示一个条目,列出所有账户的未读邮件,涵盖除垃圾邮件、已发送、归档、废纸篓和草稿之外的所有文件夹。" + }, + "cross_starred": { + "label": "所有账户:加星标", + "description": "在“所有账户”部分显示一个条目,列出所有账户的加星标邮件,涵盖除垃圾邮件、已发送、归档、废纸篓和草稿之外的所有文件夹。" + }, + "cross_all": { + "label": "所有账户:全部邮件", + "description": "在“所有账户”部分显示一个条目,列出所有账户的全部邮件,涵盖除垃圾邮件、已发送、归档、废纸篓和草稿之外的所有文件夹。" } }, "keywords": { @@ -1186,6 +1201,7 @@ "archive": "归档", "tag": "标签", "spam": "标记为垃圾邮件", + "not_spam": "非垃圾邮件", "none_selected": "未选择任何操作", "mode_label": "显示模式", "mode_inline": "内嵌", diff --git a/stores/__tests__/email-store-unified-actions.test.ts b/stores/__tests__/email-store-unified-actions.test.ts index e772a99b..5256ee0e 100644 --- a/stores/__tests__/email-store-unified-actions.test.ts +++ b/stores/__tests__/email-store-unified-actions.test.ts @@ -64,10 +64,12 @@ describe('unified-view single-email action routing (#281)', () => { activeClient = makeClient(); accountBClient = makeClient(); - // Route account-b to its own client; account-a falls back to the active one. + // Route each login by its AccountEntry.id (the `sourceClientAccountId` key). + // account-a is the active login; account-b is a second direct login; the + // active login (account-a) also delegates access to the shared owner 'owner-x'. useAuthStore.setState({ getClientForAccount: (id: string) => - (id === 'account-b' ? accountBClient : undefined) as never, + (id === 'account-b' ? accountBClient : id === 'account-a' ? activeClient : undefined) as never, } as never); useEmailStore.setState({ @@ -76,18 +78,27 @@ describe('unified-view single-email action routing (#281)', () => { viewingAccountId: null, selectedMailbox: '', mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })], + // Owner mailbox lists are cached by their JMAP id (`sourceAccountId`). accountMailboxes: { 'account-a': [makeMailbox({ id: 'a-inbox', role: 'inbox' })], 'account-b': [ makeMailbox({ id: 'b-inbox', role: 'inbox' }), makeMailbox({ id: 'b-archive', name: 'Archive', role: 'archive' }), ], + // Shared owner reached through account-a's client. + 'owner-x': [ + makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }), + makeMailbox({ id: 'owner-x:x-trash', originalId: 'x-trash', name: 'Trash', role: 'trash', isShared: true, accountId: 'owner-x' }), + ], }, processingReadStatus: new Set(), selectedEmail: null, selectedEmailIds: new Set(), emails: [ - makeEmail({ id: 'email-b', accountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }), + // Second direct login: sourceClientAccountId === sourceAccountId === 'account-b'. + makeEmail({ id: 'email-b', accountId: 'account-b', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }), + // Shared/group source: reached via account-a's client, owned by 'owner-x'. + makeEmail({ id: 'email-shared', accountId: 'owner-x', sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }), ], }); }); @@ -95,24 +106,36 @@ describe('unified-view single-email action routing (#281)', () => { it('routes markAsRead to the email’s account client', async () => { await useEmailStore.getState().markAsRead(activeClient, 'email-b', true); - expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, undefined); + expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, 'account-b'); expect(activeClient.markAsRead).not.toHaveBeenCalled(); }); - it('routes toggleStar to the email’s account client', async () => { + it('routes toggleStar to the email’s account client with the owner accountId', async () => { await useEmailStore.getState().toggleStar(activeClient, 'email-b'); - expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true); + expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true, 'account-b'); expect(activeClient.toggleStar).not.toHaveBeenCalled(); }); it('routes moveToMailbox to the email’s account client with that account’s destination', async () => { await useEmailStore.getState().moveToMailbox(activeClient, 'email-b', 'b-archive'); - expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', undefined); + expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', 'account-b'); expect(activeClient.moveEmail).not.toHaveBeenCalled(); }); + it('routes a shared/group email through the delegating login client + owner accountId', async () => { + await useEmailStore.getState().markAsRead(activeClient, 'email-shared', true); + // Reached via account-a's client (the active one), targeting the owner account. + expect(activeClient.markAsRead).toHaveBeenCalledWith('email-shared', true, 'owner-x'); + expect(accountBClient.markAsRead).not.toHaveBeenCalled(); + }); + + it('stars a shared/group email via the delegating client + owner accountId', async () => { + await useEmailStore.getState().toggleStar(activeClient, 'email-shared'); + expect(activeClient.toggleStar).toHaveBeenCalledWith('email-shared', true, 'owner-x'); + }); + it('still uses the active/passed client outside unified view', async () => { useEmailStore.setState({ isUnifiedView: false, diff --git a/stores/__tests__/folder-management.test.ts b/stores/__tests__/folder-management.test.ts index 72457343..f284d91d 100644 --- a/stores/__tests__/folder-management.test.ts +++ b/stores/__tests__/folder-management.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { useEmailStore } from '../email-store'; import type { Mailbox } from '@/lib/jmap/types'; +import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types'; function makeMailbox(overrides: Partial = {}): Mailbox { return { @@ -266,4 +267,41 @@ describe('email-store folder management', () => { expect(useEmailStore.getState().error).toBe('Role update failed'); }); }); + + // Regression: a background fetchMailboxes (e.g. push-driven after deleting + // drafts in "All Drafts") must not reset a virtual unified/cross-view selection + // to the inbox, which would jump the user out of the view they're in. + describe('fetchMailboxes selection preservation', () => { + it('keeps a unified-view selection (e.g. All Drafts) on background refresh', async () => { + useEmailStore.setState({ + mailboxes: [inbox, sent, trash, custom], + selectedMailbox: UNIFIED_MAILBOX_IDS.drafts, + isUnifiedView: true, + }); + // Fresh list (not initial load) that does NOT contain the virtual id. + const client = makeMockClient({ + getAllMailboxes: vi.fn().mockResolvedValue([inbox, sent, trash, custom]), + }); + + await useEmailStore.getState().fetchMailboxes(client); + + expect(useEmailStore.getState().selectedMailbox).toBe(UNIFIED_MAILBOX_IDS.drafts); + }); + + it('still falls back to inbox when a real selection no longer exists', async () => { + useEmailStore.setState({ + mailboxes: [inbox, sent, trash, custom], + selectedMailbox: 'custom-1', + isUnifiedView: false, + }); + // custom-1 is gone from the refreshed list. + const client = makeMockClient({ + getAllMailboxes: vi.fn().mockResolvedValue([inbox, sent, trash]), + }); + + await useEmailStore.getState().fetchMailboxes(client); + + expect(useEmailStore.getState().selectedMailbox).toBe('inbox-1'); + }); + }); }); diff --git a/stores/email-store.ts b/stores/email-store.ts index 498e6173..6e4a764a 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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; // 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; // 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; + // 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; markAsSpam: (client: IJMAPClient, emailId: string) => Promise; undoSpam: (client: IJMAPClient, emailId: string) => Promise; batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise; @@ -217,6 +224,9 @@ interface EmailStore { loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise; refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise; exitUnifiedView: () => void; + // Cross-account view operations (unread / starred / all) + fetchCrossView: (accounts: UnifiedAccountClient[], view: CrossView) => Promise; + refreshCrossCounts: (accounts: UnifiedAccountClient[]) => void; fetchScheduledEmails: (client: IJMAPClient) => Promise; loadMoreScheduledEmails: (client: IJMAPClient) => Promise; @@ -350,33 +360,39 @@ function buildAllMailFilter(jmapMailboxIds: string[]): Record { * 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(); @@ -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 }, + 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((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((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((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((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((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((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((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((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((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((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((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((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((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((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((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(); + // 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(); 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((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((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((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((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((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((set, get) => ({ const emailIdsArray = Array.from(selectedEmailIds); if (get().isUnifiedView) { - // Group emails by accountId for cross-account operations - const emailsByAccount = new Map(); + // Group by owning JMAP account; dispatch through the reaching login client. + const bySource = new Map(); 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((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((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(); + // 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(); 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(); - 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((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((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((set, get) => ({ const emailIdsArray = Array.from(selectedEmailIds); if (get().isUnifiedView) { - // Group emails by accountId for cross-account operations - const emailsByAccount = new Map(); + // 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(); 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((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((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((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((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((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((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((set, get) => ({ error: null, isUnifiedView: true, unifiedRole: role, + crossView: null, selectedKeyword: null, }); try { @@ -2856,10 +3071,50 @@ export const useEmailStore = create((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(), }); }, diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 38f44c63..f119fda7 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -220,6 +220,15 @@ interface SettingsState { // configured, in which case the view defaults to all non-special (no-role) // folders of the active account. enableAllMailView: boolean; + + // Cross-account "All accounts" views (gated per-view by the admin policy) + enableCrossUnreadView: boolean; + enableCrossStarredView: boolean; + enableCrossAllView: boolean; + + // Folder ids merged into the virtual "All Mail" mailbox. `null` = never + // configured, in which case the view defaults to all non-special (no-role) + // folders of the active account. allMailFolderIds: string[] | null; // Email Display @@ -409,6 +418,10 @@ const DEFAULT_SETTINGS = { enableAllMailView: false, allMailFolderIds: null as string[] | null, + enableCrossUnreadView: false, + enableCrossStarredView: false, + enableCrossAllView: false, + // Email Display disableThreading: false, @@ -583,6 +596,9 @@ export const useSettingsStore = create()( includeGroupInUnified: state.includeGroupInUnified, enableAllMailView: state.enableAllMailView, allMailFolderIds: state.allMailFolderIds, + enableCrossUnreadView: state.enableCrossUnreadView, + enableCrossStarredView: state.enableCrossStarredView, + enableCrossAllView: state.enableCrossAllView, senderFavicons: state.senderFavicons, showAvatarsInJunk: state.showAvatarsInJunk, colorfulSidebarIcons: state.colorfulSidebarIcons,