From 7c221c4a4a19414be7487972e78462d5a7cf0de7 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:42:05 +0200 Subject: [PATCH 01/16] feat(unified-mailbox): account-bounded Unified Mailbox with opt-in cross-account Rework the sidebar "All accounts" section into a "Unified Mailbox" that, by default, stays within the active login account and its shared/group folders. Merging across multiple logged-in accounts becomes an opt-in sub-option instead of the default, and the standalone per-account "All Mail" virtual folder is folded into the unified All mail / Unread / Starred entries (its folder selection now narrows those lists). Scope: - lib/unified-mailbox.ts: UnifiedAccountClient.crossIncludedMailboxIds; the cross views honor the per-account folder selection (union across accounts = the sum of each account's selection), falling back to inbox+custom when unset. - stores/email-store.ts: buildUnifiedAccountClients gains scopeToClientAccountId (the account boundary) and populates crossIncludedMailboxIds from allMailFolderIds; remove the standalone __all_mail__ fetch/search/load-more branches. - page.tsx: scope to the active account unless cross-account is active (per-user opt-in AND admin gate); the per-role unified mailboxes obey the same scope. Folding: - Drop ALL_MAIL_MAILBOX_ID (lib/jmap/types.ts); thread-list source-folder column now keys on isUnifiedView only; settings folder picker moves under the unified group and shows once any unified entry is enabled. Config: - User: new unifiedCrossAccount (default false); includeGroupInUnified default flips to true; enableAllMailView retired; the three cross-view toggles now gate the unified Unread/Starred/All mail entries. - Admin: new unifiedCrossAccountEnabled gate, default FALSE (cross-account is an admin opt-in; when off the per-user toggle is hidden and the scope is forced account-bounded at runtime). allMailViewEnabled deprecated and normalized forward into crossAllViewEnabled on policy load; cross-view gate labels reworded to "Unified Mailbox: ...". Header: the sidebar section shows "All accounts" when cross-account is active (opt-in AND admin gate AND >1 connected account), else "Unified Mailbox". Migration: - Settings persist v5 -> v6 (exported migrateSettings) - cross-active users keep cross-account; All-Mail-only users get the account-bounded unified All mail entry with folder ids preserved; includeGroupInUnified enabled for every migrated config; fresh installs are account-bounded. - Admin policy: one-shot, marker-guarded migratePolicyUnifiedMailbox (run before configManager.load) enables unifiedCrossAccountEnabled when a cross view was active, so existing cross-account installs keep the behaviour despite the default-false gate. Skipped on read-only config dirs. Locales: sidebar all_accounts (original label) + unified_mailbox (translated, per locale) keys; dead standalone all_mail strings removed across all 20 locales. Docs: FEATURES.md updated to the account-bounded model, the cross-account gate, and the folder-narrowed aggregate entries. Verification: tsc clean, eslint clean, full vitest suite green (incl. translations completeness, cross-view/migration coverage, and the admin policy migration test). --- FEATURES.md | 9 +- app/(main)/[locale]/page.tsx | 45 ++++--- app/(main)/admin/_tabs/policy.tsx | 12 +- components/email/thread-list-item.tsx | 6 +- components/layout/sidebar.tsx | 21 +-- components/settings/layout-settings.tsx | 47 ++++--- instrumentation.node.ts | 3 +- lib/__tests__/unified-mailbox-cross.test.ts | 36 ++++++ lib/admin/__tests__/migrate-policy.test.ts | 59 +++++++++ lib/admin/config-manager.ts | 21 ++- lib/admin/migrate.ts | 60 +++++++++ lib/admin/types.ts | 3 + lib/jmap/types.ts | 19 +-- lib/unified-mailbox.ts | 24 +++- locales/cs/common.json | 7 +- locales/da/common.json | 7 +- locales/de/common.json | 7 +- locales/en/common.json | 25 ++-- locales/es/common.json | 7 +- locales/fa/common.json | 7 +- locales/fr/common.json | 7 +- locales/hu/common.json | 7 +- locales/it/common.json | 7 +- locales/ja/common.json | 7 +- locales/ko/common.json | 7 +- locales/lv/common.json | 7 +- locales/nl/common.json | 7 +- locales/pl/common.json | 7 +- locales/pt/common.json | 7 +- locales/ro/common.json | 7 +- locales/ru/common.json | 7 +- locales/tr/common.json | 7 +- locales/uk/common.json | 7 +- locales/zh/common.json | 7 +- .../__tests__/settings-store-all-mail.test.ts | 42 +++++- stores/email-store.ts | 122 ++++++------------ stores/settings-store.ts | 111 ++++++++++------ 37 files changed, 541 insertions(+), 257 deletions(-) create mode 100644 lib/admin/__tests__/migrate-policy.test.ts diff --git a/FEATURES.md b/FEATURES.md index 3fb35ec4..6a78de96 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,9 +4,8 @@ - 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 – 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 +- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account +- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message - 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 @@ -117,7 +116,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 +- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("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`) @@ -125,7 +124,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 +- Admin policy gates for the Unified Mailbox – enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); 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/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 3b0550d1..64947e0d 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, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types"; +import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_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"; @@ -356,10 +356,7 @@ export default function Home() { useProMultiAccountMailboxes(); const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox); - const enableAllMailView = useSettingsStore((s) => s.enableAllMailView); 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 @@ -377,18 +374,36 @@ export default function Home() { const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails; const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading; const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified); + const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount); + const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled')); const accounts = useAccountStore((s) => s.accounts); const connectedAccountsSignature = useMemo( () => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","), [accounts], ); + // Cross-account is "active" when the user opted in, the admin allows it, and + // more than one account is connected. Drives the sidebar header label: the + // old "All accounts" when spanning accounts, else "Unified Mailbox". + const crossAccountActive = + unifiedCrossAccount && + unifiedCrossAccountGate && + accounts.filter((a) => a.isConnected).length > 1; // Builds the populated UnifiedAccountClient[] used by the unified-view - // effects and one-shot actions in this page. Reads the includeGroup - // setting at call time so the latest toggle value is always honored. + // effects and one-shot actions in this page. Reads the settings at call time + // so the latest toggle values are always honored. When the cross-account + // sub-option is off, the unified mailbox stays within the active account + // boundary (its own + shared folders); when on, it spans every login account. const buildPopulatedUnifiedAccounts = useCallback(async (): Promise => { + // Cross-account scope requires both the per-user opt-in and the admin + // capability gate; otherwise stay within the active account boundary. + const crossAccount = useSettingsStore.getState().unifiedCrossAccount + && usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled'); return buildUnifiedAccountClients({ includeGroup: useSettingsStore.getState().includeGroupInUnified, + scopeToClientAccountId: crossAccount + ? undefined + : (useAccountStore.getState().activeAccountId ?? undefined), }); }, []); @@ -1025,7 +1040,7 @@ export default function Home() { if (built.length < 2 && !hasGroupEntry && !isEmbedded) return; refreshUnifiedCounts(built); }); - }, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]); + }, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, 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 @@ -2413,14 +2428,12 @@ export default function Home() { // Get current mailbox name for mobile header const currentMailboxName = isScheduledView ? t('sidebar.scheduled') - : selectedMailbox === ALL_MAIL_MAILBOX_ID - ? t('sidebar.mailboxes.all_mail') - : (() => { - const mb = mailboxes.find(m => m.id === selectedMailbox); - return mb - ? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)) - : "Inbox"; - })(); + : (() => { + const mb = mailboxes.find(m => m.id === selectedMailbox); + return mb + ? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`)) + : "Inbox"; + })(); const isFocusedMailLayout = mailLayout === 'focus'; const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet; const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail); @@ -2708,7 +2721,7 @@ export default function Home() { selectedKeyword={selectedKeyword} scheduledTotal={scheduledTotal} showScheduledMailbox={delayedSendSupported} - showAllMailMailbox={showAllMailMailbox} + crossAccountActive={crossAccountActive} showCrossUnread={showCrossUnread} showCrossStarred={showCrossStarred} showCrossAll={showCrossAll} diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index 56eb65e6..1561f756 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -6,7 +6,9 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types'; import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; -const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled']; +// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized +// forward on policy load), so it is hidden from the admin UI. +const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled']; const FEATURE_GATE_LABELS: Partial> = { sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' }, @@ -22,10 +24,10 @@ const FEATURE_GATE_LABELS: Partial( const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; // Show the originating folder in the aggregate "All …" views. - const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder; + const showSourceFolder = isUnifiedView && !!email.sourceFolder; const getAccountById = useAccountStore((state) => state.getAccountById); const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined; const isChecked = selectedEmailIds.has(email.id); @@ -459,7 +459,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 diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 507c0227..a5db06e5 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -79,9 +79,10 @@ interface SidebarProps { onRefreshMailboxes?: () => void; scheduledTotal?: number; 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. */ + /** True when the unified view spans multiple login accounts (cross-account). + * Drives the section header: "All accounts" when true, else "Unified Mailbox". */ + crossAccountActive?: boolean; + /** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */ showCrossUnread?: boolean; showCrossStarred?: boolean; showCrossAll?: boolean; @@ -692,7 +693,7 @@ export function Sidebar({ onRefreshMailboxes, scheduledTotal = 0, showScheduledMailbox = false, - showAllMailMailbox = false, + crossAccountActive = false, showCrossUnread = false, showCrossStarred = false, showCrossAll = false, @@ -1017,20 +1018,10 @@ export function Sidebar({ {/* Mailbox List */}
- {showAllMailMailbox && ( - } - label={t('mailboxes.all_mail')} - depth={0} - isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'} - onClick={() => onMailboxSelect?.('__all_mail__')} - isCollapsed={isCollapsed} - /> - )} {(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
s.accounts); const activeAccountId = useAccountStore(s => s.activeAccountId); 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 connectedAccountCount = useMemo(() => accounts.filter(a => a.isConnected).length, [accounts]); + const unifiedCrossAccountAllowed = isFeatureEnabled('unifiedCrossAccountEnabled'); + // Unified Mailbox entries (All mail / Unread / Starred), each gated independently + // by the admin. Scope (single account vs. cross-account) is governed by + // `unifiedCrossAccount`; the folder picker below narrows which own folders feed them. 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; + // The folder picker narrows the own folders included in the entries above; show + // it once the user has enabled at least one of them. + const anyCrossEnabled = enableCrossUnreadView || enableCrossStarredView || enableCrossAllView; + const anyCrossAllowed = crossViews.some(c => c.allowed); // Own (non-shared) folders and the active account's All Mail selection. The // selection is per account: a missing entry = never configured, which @@ -244,6 +251,21 @@ export function LayoutSettings() { )} + {enableUnifiedMailbox && connectedAccountCount > 1 && unifiedCrossAccountAllowed && !isSettingHidden('unifiedCrossAccount') && ( +
+ + updateSetting('unifiedCrossAccount', v)} + /> + +
+ )} + {enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
)} - {enableUnifiedMailbox && crossViews.some(c => c.allowed) && ( + {enableUnifiedMailbox && anyCrossAllowed && (
+ {crossViews.map(({ setting, value, allowed, labelKey, descKey }) => ( allowed && !isSettingHidden(setting) && ( )} - {allMailViewAllowed && !isSettingHidden('enableAllMailView') && ( - - updateSetting('enableAllMailView', v)} - /> - - )} - - {allMailViewAllowed && enableAllMailView && ( + {enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
+
{t('all_mail.folders_label')}
{t('all_mail.folders_description')}
diff --git a/instrumentation.node.ts b/instrumentation.node.ts index 13438a52..b98c2f9f 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -1,7 +1,7 @@ import { readFileSync } from "fs"; import { configManager } from "./lib/admin/config-manager"; import { initAdminPassword } from "./lib/admin/password"; -import { migrateLegacyAdminLayout } from "./lib/admin/migrate"; +import { migrateLegacyAdminLayout, migratePolicyUnifiedMailbox } from "./lib/admin/migrate"; import { detectSetupState } from "./lib/setup/state"; import { ensureSetupToken } from "./lib/setup/token"; @@ -14,6 +14,7 @@ console.info(`Bulwark Webmail v${current}`); // Initialize admin config and password bootstrap. Migration runs first so // existing v1 layouts are split before anything reads admin.json. migrateLegacyAdminLayout() + .then(() => migratePolicyUnifiedMailbox()) .then(() => configManager.load()) .then(() => initAdminPassword()) .then(async () => { diff --git a/lib/__tests__/unified-mailbox-cross.test.ts b/lib/__tests__/unified-mailbox-cross.test.ts index 866137d7..df4ef361 100644 --- a/lib/__tests__/unified-mailbox-cross.test.ts +++ b/lib/__tests__/unified-mailbox-cross.test.ts @@ -42,6 +42,26 @@ describe('getCrossIncludedMailboxes', () => { const ids = getCrossIncludedMailboxes(account).map((m) => m.id); expect(ids).toEqual(['inbox', 'projects']); }); + + it('honors an explicit crossIncludedMailboxIds selection (folder picker)', () => { + const account = makeAccount({ + accountId: 'a', + mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined), mb('archive', 'archive')], + // user picked inbox + archive, excluded projects - overrides role exclusion + crossIncludedMailboxIds: ['inbox', 'archive'], + }); + const ids = getCrossIncludedMailboxes(account).map((m) => m.id); + expect(ids).toEqual(['inbox', 'archive']); + }); + + it('an empty selection yields no folders', () => { + const account = makeAccount({ + accountId: 'a', + mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined)], + crossIncludedMailboxIds: [], + }); + expect(getCrossIncludedMailboxes(account)).toEqual([]); + }); }); describe('buildCrossFilter', () => { @@ -86,6 +106,22 @@ describe('getCrossUnreadTotal', () => { }); expect(getCrossUnreadTotal([a, b])).toBe(10); }); + + it('counts only the selected folders when crossIncludedMailboxIds is set; shared accounts stay unrestricted', () => { + // personal account narrowed to inbox only (projects excluded by the picker) + const personal = makeAccount({ + accountId: 'a', + mailboxes: [mb('inbox', 'inbox', 3), mb('projects', undefined, 4)], + crossIncludedMailboxIds: ['inbox'], + }); + // shared account unrestricted -> role-exclusion default (inbox + custom) + const shared = makeAccount({ + accountId: 'owner', + isShared: true, + mailboxes: [mb('ns:inbox', 'inbox', 5, 'orig-inbox'), mb('ns:team', undefined, 2, 'orig-team'), mb('ns:junk', 'junk', 9, 'orig-junk')], + }); + expect(getCrossUnreadTotal([personal, shared])).toBe(3 + 5 + 2); + }); }); describe('resolveSourceFolderName', () => { diff --git a/lib/admin/__tests__/migrate-policy.test.ts b/lib/admin/__tests__/migrate-policy.test.ts new file mode 100644 index 00000000..0e166cd5 --- /dev/null +++ b/lib/admin/__tests__/migrate-policy.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { migratePolicyUnifiedMailbox } from '../migrate'; + +// migratePolicyUnifiedMailbox reads ADMIN_CONFIG_DIR at call time (see paths.ts), +// so each test points it at a fresh temp dir. +let dir: string; +const policyPath = () => path.join(dir, 'policy.json'); +const markerPath = () => path.join(dir, '.migrated-unified-mailbox'); + +const writePolicy = (features: Record) => + writeFile(policyPath(), JSON.stringify({ features, restrictions: {} }, null, 2), 'utf-8'); +const readFeatures = async () => + JSON.parse(await readFile(policyPath(), 'utf-8')).features as Record; + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-')); + process.env.ADMIN_CONFIG_DIR = dir; +}); + +afterEach(async () => { + delete process.env.ADMIN_CONFIG_DIR; + await rm(dir, { recursive: true, force: true }); +}); + +describe('migratePolicyUnifiedMailbox', () => { + it('enables unifiedCrossAccountEnabled when a cross view was active', async () => { + await writePolicy({ crossUnreadViewEnabled: true }); + await migratePolicyUnifiedMailbox(); + expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true); + expect(existsSync(markerPath())).toBe(true); + }); + + it('does not enable it for a standalone All-Mail-only policy', async () => { + await writePolicy({ allMailViewEnabled: true, crossUnreadViewEnabled: false, crossStarredViewEnabled: false, crossAllViewEnabled: false }); + await migratePolicyUnifiedMailbox(); + expect((await readFeatures()).unifiedCrossAccountEnabled).toBeUndefined(); + }); + + it('is a one-shot: a later admin disable survives a re-run', async () => { + await writePolicy({ crossAllViewEnabled: true }); + await migratePolicyUnifiedMailbox(); + expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true); + + // Admin turns it back off; the marker is present, so re-running is a no-op. + await writePolicy({ crossAllViewEnabled: true, unifiedCrossAccountEnabled: false }); + await migratePolicyUnifiedMailbox(); + expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(false); + }); + + it('no policy.json: writes the marker and does not throw', async () => { + await migratePolicyUnifiedMailbox(); + expect(existsSync(markerPath())).toBe(true); + expect(existsSync(policyPath())).toBe(false); + }); +}); diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index 1e3e278d..cfa068a2 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -33,12 +33,12 @@ class ConfigManager { this.adminConfig = await this.readJsonFile('config.json') || {}; const policy = await this.readJsonFile('policy.json'); if (policy) { - this.policyCache = { + this.policyCache = ConfigManager.normalizePolicy({ ...DEFAULT_POLICY, ...policy, features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) }, themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) }, - }; + }); } else { this.policyCache = { ...DEFAULT_POLICY }; } @@ -166,15 +166,28 @@ class ConfigManager { */ async setPolicy(policy: SettingsPolicy): Promise { assertWritable('update settings policy'); - this.policyCache = { + this.policyCache = ConfigManager.normalizePolicy({ ...DEFAULT_POLICY, ...policy, features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) }, themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) }, - }; + }); await this.writeJsonFile('policy.json', this.policyCache as unknown as Record); } + /** + * Migrates deprecated feature gates forward. The standalone "All Mail" view + * (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an + * admin who enabled it keeps that entry available via `crossAllViewEnabled`. + * Idempotent - safe to run on every load. + */ + private static normalizePolicy(policy: SettingsPolicy): SettingsPolicy { + if (policy.features.allMailViewEnabled) { + policy.features.crossAllViewEnabled = true; + } + return policy; + } + /** * Reload config from disk (for manual file edits or multi-instance). */ diff --git a/lib/admin/migrate.ts b/lib/admin/migrate.ts index a6188d12..53375e77 100644 --- a/lib/admin/migrate.ts +++ b/lib/admin/migrate.ts @@ -11,6 +11,7 @@ import { import type { AdminConfigData, AdminStateData } from './types'; const MIGRATION_MARKER = '.migrated-v2'; +const POLICY_UNIFIED_MARKER = '.migrated-unified-mailbox'; interface LegacyAdminData { passwordHash: string; @@ -59,6 +60,65 @@ export async function migrateLegacyAdminLayout(): Promise { } } +/** + * One-shot policy migration for the Unified Mailbox rework. Before it, the + * cross views (crossUnread/crossStarred/crossAll) merged across every logged-in + * account, so an admin who had any of them enabled was already permitting + * cross-account aggregation. The new `unifiedCrossAccountEnabled` gate (default + * false) controls that capability, so enable it whenever a cross view was active + * - otherwise existing cross-account installs would silently lose the behaviour + * on upgrade (the per-user `unifiedCrossAccount` is AND-ed with this gate). + * + * Persisted + marker-guarded (not a per-load normalization) so a later admin + * decision to disable the gate survives restarts. Skipped on read-only config + * dirs - operators who locked their config must migrate manually (mirrors + * migrateLegacyAdminLayout). The deprecated `allMailViewEnabled` (a single-account + * view, never cross-account) deliberately does NOT trigger this. + */ +export async function migratePolicyUnifiedMailbox(): Promise { + if (isConfigReadOnly()) return; + + const markerPath = getConfigPath(POLICY_UNIFIED_MARKER); + if (existsSync(markerPath)) return; + + try { + const policyPath = getConfigPath('policy.json'); + if (existsSync(policyPath)) { + let parsed: Record | null = null; + try { + parsed = JSON.parse(await readFile(policyPath, 'utf-8')) as Record; + } catch { + logger.warn('policy.json is not valid JSON; skipping Unified Mailbox policy migration'); + } + const features = + parsed && typeof parsed.features === 'object' && parsed.features + ? (parsed.features as Record) + : null; + if (features) { + const hadCrossAccount = !!( + features.crossUnreadViewEnabled || + features.crossStarredViewEnabled || + features.crossAllViewEnabled + ); + if (hadCrossAccount && features.unifiedCrossAccountEnabled !== true) { + features.unifiedCrossAccountEnabled = true; + const tmp = policyPath + '.tmp'; + await writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8'); + await rename(tmp, policyPath); + logger.info('Migrated policy: enabled unifiedCrossAccountEnabled (cross-account views were active)'); + } + } + } + + await ensureConfigDir(); + await writeFile(markerPath, new Date().toISOString(), 'utf-8'); + } catch (error) { + logger.warn('Unified Mailbox policy migration failed; will retry on next boot', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } +} + /** * If the existing admin.json carries timestamp fields (legacy mixed layout), * split them into admin-state.json and rewrite admin.json without them. diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 57c9aa61..fd315d5b 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -60,10 +60,12 @@ export interface FeatureGates { hoverActionsConfigEnabled: boolean; filesEnabled: boolean; contactsEnabled: boolean; + /** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */ allMailViewEnabled: boolean; crossUnreadViewEnabled: boolean; crossStarredViewEnabled: boolean; crossAllViewEnabled: boolean; + unifiedCrossAccountEnabled: boolean; } export const DEFAULT_FEATURE_GATES: FeatureGates = { @@ -89,6 +91,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = { crossUnreadViewEnabled: false, crossStarredViewEnabled: false, crossAllViewEnabled: false, + unifiedCrossAccountEnabled: false, }; export interface ThemePolicy { diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index fe5ca45c..49747613 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -892,19 +892,12 @@ export function isUnifiedMailboxId(id: string): boolean { } /** - * Virtual mailbox id for the gated "All Mail" view: every folder of a single - * account merged into one date-sorted list. Distinct from the unified mailbox - * ids above, which merge one role across multiple accounts. Which folders are - * 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). + * Cross views shown in the unified ("Unified Mailbox") section: All mail / + * Unread / Starred. Each merges messages across the account boundary (the active + * account + its shared folders by default, or every logged-in account when the + * cross-account sub-option is on), narrowed by the user's folder selection (see + * `allMailFolderIds`). Distinct from the per-role unified ids (one role across + * accounts). */ export const CROSS_UNREAD = '__cross_unread__'; export const CROSS_STARRED = '__cross_starred__'; diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index 5789068a..c16f6c25 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -22,6 +22,18 @@ export interface UnifiedAccountClient { // must use the mailbox's `originalId` and explicitly target this accountId // so the server routes to the owner's data. isShared?: boolean; + // Store-side mailbox ids that make up THIS account's contribution to the + // cross views (All mail / Unread / Starred). It is intentionally per-account, + // not a global list: mailbox ids are account-scoped, so an id from one account + // is meaningless in another. The effective folder set of a cross view is the + // UNION across every account's entry (one UnifiedAccountClient per account), + // i.e. the sum of the respective per-account selections. + // + // For personal accounts this is the user's folder selection + // (`allMailFolderIds[accountId]`); shared/group accounts are not individually + // configurable and leave this undefined. When undefined, getCrossIncludedMailboxes + // falls back to the role-exclusion default (inbox + custom folders). + crossIncludedMailboxIds?: string[]; } export interface UnifiedFetchResult { @@ -313,10 +325,18 @@ export function fetchUnifiedMailboxCounts( // 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). + * Mailboxes of an account included in the cross views (All mail / Unread / + * Starred). When the account carries an explicit `crossIncludedMailboxIds` + * selection (personal accounts honor the user's folder picker, shared accounts + * include everything), only those mailboxes are used. Otherwise it falls back + * to the role-exclusion default: everything whose role is not excluded (inbox + + * custom/no-role folders). */ export function getCrossIncludedMailboxes(account: UnifiedAccountClient): Mailbox[] { + if (account.crossIncludedMailboxIds) { + const selected = new Set(account.crossIncludedMailboxIds); + return account.mailboxes.filter((m) => selected.has(m.id)); + } return account.mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? '')); } diff --git a/locales/cs/common.json b/locales/cs/common.json index 577cc390..6a62a706 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -114,6 +114,7 @@ "unified_archive": "Všechny archivy", "unified_junk": "Všechen spam", "all_accounts": "Všechny účty", + "unified_mailbox": "Sjednocená schránka", "expand": "Rozbalit", "collapse": "Sbalit", "expand_tooltip": "Rozbalit", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Sjednocená schránka", "description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Zahrnout skupinové schránky", "description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/da/common.json b/locales/da/common.json index 334b635c..f64f5948 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -114,6 +114,7 @@ "unified_archive": "Alle arkiver", "unified_junk": "Alt spam", "all_accounts": "Alle konti", + "unified_mailbox": "Samlet postkasse", "expand": "Udvid", "collapse": "Skjul", "expand_tooltip": "Udvid", @@ -927,14 +928,16 @@ "unified_mailbox": { "label": "Samlet postkasse", "description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Inkluder gruppepostkasser", "description": "Inkluder også delte/gruppepostkasser i den samlede visning." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/de/common.json b/locales/de/common.json index 02b42581..a7b35e0a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -114,6 +114,7 @@ "unified_archive": "Alle Archive", "unified_junk": "Alle Spam", "all_accounts": "Alle Konten", + "unified_mailbox": "Gemeinsames Postfach", "expand": "Erweitern", "collapse": "Einklappen", "expand_tooltip": "Erweitern", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Gemeinsames Postfach", "description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen", + "cross_account": { + "label": "Über alle Konten", + "description": "Den vereinheitlichten Posteingang über alle verbundenen Konten zusammenführen, statt beim aktiven Konto zu bleiben." + }, "include_group": { "label": "Gruppenpostfächer einbeziehen", "description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Gilt für {account}.", diff --git a/locales/en/common.json b/locales/en/common.json index 7de12348..c2a6d529 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -114,6 +114,7 @@ "unified_archive": "All Archive", "unified_junk": "All Junk", "all_accounts": "All Accounts", + "unified_mailbox": "Unified Mailbox", "expand": "Expand", "collapse": "Collapse", "expand_tooltip": "Expand", @@ -926,17 +927,19 @@ }, "unified_mailbox": { "label": "Unified Mailbox", - "description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts", + "description": "Show combined folders (Inbox, Sent, etc.) for the active account and its shared folders.", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Include group inboxes", "description": "Also merge shared/group inboxes into the unified view." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", - "folders_label": "Folders in All Mail", - "folders_description": "Choose which folders are merged into the All Mail view.", + "folders_label": "Folders in the unified lists", + "folders_description": "Choose which of this account's folders are merged into the All mail / Unread / Starred lists.", "account_hint": "Applies to {account}.", "no_folders": "No folders available." }, @@ -959,16 +962,16 @@ "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." + "label": "Unread", + "description": "Show an Unread entry in the Unified Mailbox listing unread mail across the selected folders." }, "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." + "label": "Starred", + "description": "Show a Starred entry in the Unified Mailbox listing flagged/starred mail across the selected folders." }, "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." + "label": "All mail", + "description": "Show an All mail entry in the Unified Mailbox listing all mail across the selected folders." } }, "keywords": { diff --git a/locales/es/common.json b/locales/es/common.json index 7110b9be..5fe89614 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -114,6 +114,7 @@ "unified_archive": "Todos los archivos", "unified_junk": "Todo el spam", "all_accounts": "Todas las cuentas", + "unified_mailbox": "Buzón unificado", "expand": "Expandir", "collapse": "Contraer", "expand_tooltip": "Expandir", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Buzón unificado", "description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Incluir buzones de grupo", "description": "Incluir también los buzones compartidos o de grupo en la vista unificada." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/fa/common.json b/locales/fa/common.json index cfd9e953..2c55b1f7 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -114,6 +114,7 @@ "unified_archive": "همه بایگانی‌ها", "unified_junk": "همه هرزنامه‌ها", "all_accounts": "همه حساب‌ها", + "unified_mailbox": "صندوق پستی یکپارچه", "expand": "باز کردن", "collapse": "جمع کردن", "expand_tooltip": "باز کردن", @@ -927,14 +928,16 @@ "unified_mailbox": { "label": "صندوق پستی یکپارچه", "description": "نمایش پوشه‌های ترکیبی در همه حساب‌های متصل", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "شامل صندوق ورودی گروه‌ها", "description": "صندوق ورودی گروه‌های اشتراکی را هم ادغام کن" } }, "all_mail": { - "label": "همه ایمیل‌ها", - "description": "نمایش گزینه \"همه ایمیل‌ها\" بالای پوشه‌ها", "folders_label": "پوشه‌ها در همه ایمیل‌ها", "folders_description": "انتخاب کنید کدام پوشه‌ها ادغام شوند", "no_folders": "پوشه‌ای موجود نیست", diff --git a/locales/fr/common.json b/locales/fr/common.json index 8d0c2ac7..4bf45769 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -114,6 +114,7 @@ "unified_archive": "Toutes les archives", "unified_junk": "Tous les indésirables", "all_accounts": "Tous les comptes", + "unified_mailbox": "Boîte aux lettres unifiée", "expand": "Développer", "collapse": "Réduire", "expand_tooltip": "Développer", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Boîte aux lettres unifiée", "description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Inclure les boîtes de groupe", "description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 24a18433..de0a5aec 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -114,6 +114,7 @@ "unified_archive": "Összes archívum", "unified_junk": "Összes spam", "all_accounts": "Minden fiók", + "unified_mailbox": "Egységes postafiók", "expand": "Kibontás", "collapse": "Összecsukás", "expand_tooltip": "Kibontás", @@ -927,14 +928,16 @@ "unified_mailbox": { "label": "Egységes postafiók", "description": "Összevont mappák (Beérkező, Elküldött, stb.) megjelenítése az összes csatlakoztatott fiók között", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Csoportos postafiókok belefoglalása", "description": "Megosztott/csoportos postafiókok egyesítése az egységes nézetbe." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/it/common.json b/locales/it/common.json index 1721864c..9ff22eb6 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -114,6 +114,7 @@ "unified_archive": "Tutti gli archivi", "unified_junk": "Tutto lo spam", "all_accounts": "Tutti gli account", + "unified_mailbox": "Casella di posta unificata", "expand": "Espandi", "collapse": "Comprimi", "expand_tooltip": "Espandi", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Casella di posta unificata", "description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Includi le caselle di gruppo", "description": "Includi anche le caselle condivise o di gruppo nella vista unificata." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/ja/common.json b/locales/ja/common.json index d93f3b27..02c2e796 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -114,6 +114,7 @@ "unified_archive": "すべてのアーカイブ", "unified_junk": "すべての迷惑メール", "all_accounts": "すべてのアカウント", + "unified_mailbox": "統合メールボックス", "expand": "展開", "collapse": "折りたたむ", "expand_tooltip": "展開", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "統合メールボックス", "description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "グループ受信トレイを含める", "description": "共有/グループ受信トレイも統合ビューに含めます。" } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6a80e438..8ef3d28c 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -114,6 +114,7 @@ "unified_archive": "모든 보관함", "unified_junk": "모든 스팸함", "all_accounts": "모든 계정", + "unified_mailbox": "통합 메일함", "expand": "펼치기", "collapse": "접기", "expand_tooltip": "펼치기", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "통합 메일함", "description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "그룹 받은편지함 포함", "description": "공유/그룹 받은편지함도 통합 보기에 포함합니다." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/lv/common.json b/locales/lv/common.json index f9fc75b7..6936e177 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -114,6 +114,7 @@ "unified_archive": "Visi arhīvi", "unified_junk": "Viss mēstules", "all_accounts": "Visi konti", + "unified_mailbox": "Apvienotā pastkaste", "expand": "Izvērst", "collapse": "Sairt", "expand_tooltip": "Izvērst", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Apvienotā pastkaste", "description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Iekļaut grupas pastkastes", "description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 52624f8b..9b2bd109 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -114,6 +114,7 @@ "unified_archive": "Alle archieven", "unified_junk": "Alle spam", "all_accounts": "Alle accounts", + "unified_mailbox": "Gecombineerd postvak", "expand": "Uitklappen", "collapse": "Inklappen", "expand_tooltip": "Uitklappen", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Gecombineerd postvak", "description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Groepspostvakken meenemen", "description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/pl/common.json b/locales/pl/common.json index d365e95a..efae0382 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -114,6 +114,7 @@ "unified_archive": "Wszystkie archiwa", "unified_junk": "Wszystkie spam", "all_accounts": "Wszystkie konta", + "unified_mailbox": "Wspólna skrzynka", "expand": "Rozwiń", "collapse": "Zwiń", "expand_tooltip": "Rozwiń", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Wspólna skrzynka", "description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Uwzględnij skrzynki grupowe", "description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 62fd321c..3a7f506d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -114,6 +114,7 @@ "unified_archive": "Todos os arquivos", "unified_junk": "Todo o spam", "all_accounts": "Todas as contas", + "unified_mailbox": "Caixa de correio unificada", "expand": "Expandir", "collapse": "Recolher", "expand_tooltip": "Expandir", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Caixa de correio unificada", "description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Incluir caixas de grupo", "description": "Incluir também as caixas partilhadas ou de grupo na vista unificada." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/ro/common.json b/locales/ro/common.json index a6eb79c6..de26ea0b 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -114,6 +114,7 @@ "unified_archive": "Arhivă completă", "unified_junk": "Toate mesajele nedorite", "all_accounts": "Toate conturile", + "unified_mailbox": "Căsuță poștală unificată", "expand": "Extindeți", "collapse": "Reduceți", "expand_tooltip": "Extindeți", @@ -927,14 +928,16 @@ "unified_mailbox": { "label": "Căsuță poștală unificată", "description": "Afișați folderele combinate (Mesaje primite, Mesaje trimise etc.) pentru toate conturile conectate", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Includeți căsuțele de e-mail de grup", "description": "De asemenea, integrați căsuțele de e-mail partajate/de grup în vizualizarea unificată." } }, "all_mail": { - "label": "Toate mesajele", - "description": "Afișați o intrare „Toate mesajele” deasupra folderelor, care reunește mesajele din toate folderele acestui cont într-o singură listă.", "folders_label": "Dosare în „Toate mesajele”", "folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.", "account_hint": "Se aplică pentru {account}.", diff --git a/locales/ru/common.json b/locales/ru/common.json index a0962e5a..465da0f3 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -114,6 +114,7 @@ "unified_archive": "Все архивы", "unified_junk": "Весь спам", "all_accounts": "Все аккаунты", + "unified_mailbox": "Общий почтовый ящик", "expand": "Развернуть", "collapse": "Свернуть", "expand_tooltip": "Развернуть", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Общий почтовый ящик", "description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Включать групповые ящики", "description": "Также объединять общие/групповые ящики в едином представлении." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/tr/common.json b/locales/tr/common.json index 0fc1ebba..a69c19aa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -114,6 +114,7 @@ "unified_archive": "Tüm Arşiv", "unified_junk": "Tüm Önemsiz", "all_accounts": "Tüm Hesaplar", + "unified_mailbox": "Birleşik Posta Kutusu", "expand": "Genişlet", "collapse": "Daralt", "expand_tooltip": "Genişlet", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Birleşik Posta Kutusu", "description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Grup gelen kutularını dahil et", "description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/uk/common.json b/locales/uk/common.json index a916fb12..d267cb22 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -114,6 +114,7 @@ "unified_archive": "Усі архіви", "unified_junk": "Весь спам", "all_accounts": "Усі облікові записи", + "unified_mailbox": "Спільна поштова скринька", "expand": "Розгорнути", "collapse": "Згорнути", "expand_tooltip": "Розгорнути", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "Спільна поштова скринька", "description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "Включати групові скриньки", "description": "Також об'єднувати спільні/групові скриньки у спільному перегляді." } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5e151add..e34ec042 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -114,6 +114,7 @@ "unified_archive": "所有归档", "unified_junk": "所有垃圾邮件", "all_accounts": "所有账户", + "unified_mailbox": "统一邮箱", "expand": "展开", "collapse": "收起", "expand_tooltip": "展开", @@ -924,14 +925,16 @@ "unified_mailbox": { "label": "统一邮箱", "description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)", + "cross_account": { + "label": "Across all accounts", + "description": "Merge the unified mailbox across every connected account instead of staying within the active account." + }, "include_group": { "label": "包含群组收件箱", "description": "在统一视图中也合并共享/群组收件箱。" } }, "all_mail": { - "label": "All Mail", - "description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.", "folders_label": "Folders in All Mail", "folders_description": "Choose which folders are merged into the All Mail view.", "account_hint": "Applies to {account}.", diff --git a/stores/__tests__/settings-store-all-mail.test.ts b/stores/__tests__/settings-store-all-mail.test.ts index 616a8b35..541f138f 100644 --- a/stores/__tests__/settings-store-all-mail.test.ts +++ b/stores/__tests__/settings-store-all-mail.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { useSettingsStore } from '../settings-store'; +import { useSettingsStore, migrateSettings } from '../settings-store'; describe('settings-store per-account allMailFolderIds', () => { beforeEach(() => { @@ -53,3 +53,43 @@ describe('settings-store per-account allMailFolderIds', () => { }); }); }); + +describe('migrateSettings v5 -> v6 (Unified Mailbox rework)', () => { + it('keeps cross-account users cross-account when any cross view was on, and enables shared', () => { + const out = migrateSettings( + { allMailFolderIds: {}, enableCrossUnreadView: true, enableAllMailView: false, includeGroupInUnified: false }, + 5, + ) as unknown as Record; + expect(out.unifiedCrossAccount).toBe(true); + // shared inclusion is enabled for every migrated config, even if it was off + expect(out.includeGroupInUnified).toBe(true); + expect(out.enableAllMailView).toBeUndefined(); + }); + + it('folds a standalone All-Mail user into the account-bounded unified "All mail" entry', () => { + const out = migrateSettings( + { + allMailFolderIds: { 'acct-1': ['inbox', 'projects'] }, + enableAllMailView: true, + enableUnifiedMailbox: false, + enableCrossUnreadView: false, + enableCrossStarredView: false, + enableCrossAllView: false, + }, + 5, + ) as unknown as Record; + expect(out.enableUnifiedMailbox).toBe(true); + expect(out.enableCrossAllView).toBe(true); + expect(out.unifiedCrossAccount).toBe(false); // new account-bounded default + expect(out.includeGroupInUnified).toBe(true); + // folder selection carries over unchanged -> narrows the unified lists + expect(out.allMailFolderIds).toEqual({ 'acct-1': ['inbox', 'projects'] }); + expect(out.enableAllMailView).toBeUndefined(); + }); + + it('a fresh user gets account-bounded defaults', () => { + const out = migrateSettings({ allMailFolderIds: {} }, 5) as unknown as Record; + expect(out.unifiedCrossAccount).toBe(false); + expect(out.includeGroupInUnified).toBe(true); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 0967f670..6def8771 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, ALL_MAIL_MAILBOX_ID, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types"; +import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, 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"; @@ -7,7 +7,7 @@ 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, fetchCrossViewEmails, searchCrossViewEmails, getCrossUnreadTotal, resolveSourceFolderName, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; +import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; @@ -330,34 +330,19 @@ function resolveActionMailboxes(): Mailbox[] { } /** - * Resolves the JMAP mailbox ids that make up the gated "All Mail" view for the - * active/viewing account. Honors that account's `allMailFolderIds` entry; when - * not configured it defaults to every non-special (no-role) folder. Shared - * folders are excluded - All Mail is scoped to a single account. Returns - * JMAP-side ids (originalId for namespaced mailboxes). + * Resolves the store-side mailbox ids that make up a personal account's + * contribution to the unified cross views (All mail / Unread / Starred), honoring + * that account's `allMailFolderIds` folder selection. Returns `undefined` when the + * account has no explicit selection, so the cross views fall back to their + * role-exclusion default (inbox + custom folders). An explicit `[]` selection + * yields an empty list (no own folders). `ownMailboxes` must already exclude + * shared folders - the picker only ever scopes the user's own folders. */ -function resolveAllMailJmapIds(): string[] { - const mailboxes = resolveActionMailboxes().filter((mb) => !mb.isShared); - // Per-account selection: read the entry for the account the view is scoped to - // (the Pro viewing override, else the global active account). A missing entry - // = "not configured" -> all no-role folders; an explicit [] = no folders. - const accountId = useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId; - const configured = accountId ? useSettingsStore.getState().allMailFolderIds[accountId] : undefined; - const selected = configured === undefined - ? mailboxes.filter((mb) => !mb.role) - : mailboxes.filter((mb) => configured.includes(mb.id)); - return selected.map((mb) => mb.originalId || mb.id); -} - -/** - * Builds the JMAP Email/query filter for the All Mail view from a set of - * mailbox ids - an OR of `inMailbox` conditions (or a single condition). - */ -function buildAllMailFilter(jmapMailboxIds: string[]): Record { - if (jmapMailboxIds.length === 1) { - return { inMailbox: jmapMailboxIds[0] }; - } - return { operator: 'OR', conditions: jmapMailboxIds.map((id) => ({ inMailbox: id })) }; +function resolveCrossIncludedMailboxIds(accountId: string, ownMailboxes: Mailbox[]): string[] | undefined { + const configured = useSettingsStore.getState().allMailFolderIds[accountId]; + if (configured === undefined) return undefined; + const selected = new Set(configured); + return ownMailboxes.filter((mb) => selected.has(mb.id)).map((mb) => mb.id); } /** @@ -418,12 +403,24 @@ function resolveEmailActionContext( * owner account reachable through each logged-in client. The shared entries * are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP * requests via `originalId` + owner accountId. + * + * When `scopeToClientAccountId` is set, only the matching logged-in account + * (and the shared owners reachable through its client) is built - this keeps + * the unified mailbox within a single account boundary. Omitting it spans every + * logged-in account (the cross-account sub-option). + * + * Personal entries carry `crossIncludedMailboxIds` derived from the account's + * `allMailFolderIds` folder selection, restricting the All mail / Unread / + * Starred cross views to the chosen own folders (shared entries are left + * unrestricted so all their folders are included). */ export async function buildUnifiedAccountClients( - opts: { includeGroup?: boolean } = {}, + opts: { includeGroup?: boolean; scopeToClientAccountId?: string } = {}, ): Promise { - const { includeGroup = false } = opts; - const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected); + const { includeGroup = false, scopeToClientAccountId } = opts; + const authAccounts = useAccountStore.getState().accounts.filter( + (a) => a.isConnected && (!scopeToClientAccountId || a.id === scopeToClientAccountId), + ); const allClients = useAuthStore.getState().getAllConnectedClients(); const built: UnifiedAccountClient[] = []; // Per-account mailbox lists gathered here are cached into `accountMailboxes` @@ -443,7 +440,7 @@ export async function buildUnifiedAccountClients( // 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 }); + built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false, crossIncludedMailboxIds: resolveCrossIncludedMailboxIds(a.id, ownMailboxes) }); fetchedMailboxes[a.id] = ownMailboxes; // Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]` // resolves uniformly for personal and shared sources alike. @@ -840,7 +837,6 @@ export const useEmailStore = create((set, get) => ({ // doesn't exist in the fetched list (e.g. after an account switch) const currentSelectedMailbox = get().selectedMailbox; const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID - || currentSelectedMailbox === 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 @@ -904,28 +900,6 @@ export const useEmailStore = create((set, get) => ({ await get().fetchScheduledEmails(client); return; } - if (targetMailboxId === ALL_MAIL_MAILBOX_ID) { - const jmapIds = resolveAllMailJmapIds(); - if (jmapIds.length === 0) { - set({ emails: [], hasMoreEmails: false, totalEmails: 0, isLoading: false }); - return; - } - const emailsPerPage = useSettingsStore.getState().emailsPerPage; - 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, - totalEmails: result.total, - isLoading: false, - }); - return; - } const effectiveClient = resolveActionClient(client); // Find the mailbox to get its accountId (for shared folder support) @@ -1071,21 +1045,7 @@ export const useEmailStore = create((set, get) => ({ const { searchFilters } = get(); const hasFilters = !isFilterEmpty(searchFilters); - if (selectedMailbox === ALL_MAIL_MAILBOX_ID) { - if (searchQuery || hasFilters) { - // Search within All Mail spans the whole account (no inMailbox). - result = hasFilters - ? await effectiveClient.advancedSearchEmails(buildJMAPFilter(searchQuery, searchFilters, undefined), undefined, emailsPerPage, position) - : await effectiveClient.searchEmails(searchQuery, undefined, undefined, emailsPerPage, position); - } else { - const jmapIds = resolveAllMailJmapIds(); - if (jmapIds.length === 0) { - set({ hasMoreEmails: false, isLoadingMore: false }); - return; - } - result = await effectiveClient.advancedSearchEmails(buildAllMailFilter(jmapIds), undefined, emailsPerPage, position); - } - } else if (searchQuery || hasFilters) { + if (searchQuery || hasFilters) { const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const jmapMailboxId = mailbox?.originalId || selectedMailbox; @@ -1111,13 +1071,6 @@ export const useEmailStore = create((set, get) => ({ result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined, true); } - 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; @@ -1789,16 +1742,14 @@ export const useEmailStore = create((set, get) => ({ return; } - // Get the current mailbox to scope the search. In the All Mail view the - // search spans every folder of the account (no inMailbox constraint). + // Get the current mailbox to scope the search. const selectedMailbox = get().selectedMailbox; - const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); // Use originalId for shared mailboxes - const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox); + const jmapMailboxId = mailbox?.originalId || selectedMailbox; // Only pass accountId for shared mailboxes, not for primary account - const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined); + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); @@ -1883,10 +1834,9 @@ export const useEmailStore = create((set, get) => ({ return; } - const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox); - const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined); + const jmapMailboxId = mailbox?.originalId || selectedMailbox; + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 1cd4ccc5..c79e9057 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -240,22 +240,26 @@ interface SettingsState { // Unified Mailbox enableUnifiedMailbox: boolean; + // Include shared/delegated folders in the unified mailbox. Default true: the + // account-bounded unified view is defined by spanning the account's own folders + // plus every shared folder it can access. includeGroupInUnified: boolean; + // When true, the unified mailbox merges across every logged-in account + // (cross-account). When false (default for new installs) it stays within the + // active account boundary (own + shared folders). Gated by the admin + // `unifiedCrossAccountEnabled` feature. + unifiedCrossAccount: boolean; - // All Mail view (gated): user toggle (like the unified mailbox) plus the set - // of 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. - enableAllMailView: boolean; - - // Cross-account "All accounts" views (gated per-view by the admin policy) + // Unified Mailbox entries, each gated per-view by the admin policy. These show + // the All mail / Unread / Starred lists, scoped by `unifiedCrossAccount` and + // narrowed by the `allMailFolderIds` folder selection. enableCrossUnreadView: boolean; enableCrossStarredView: boolean; enableCrossAllView: boolean; - // Per-account "All Mail" folder selection, keyed by AccountEntry.id. A - // missing entry = "not configured" -> defaults to every no-role folder; an - // explicit [] = "no folders". (Replaced the legacy global string[] | null.) + // Per-account folder selection narrowing the unified All mail / Unread / + // Starred lists, keyed by AccountEntry.id. A missing entry = "not configured" + // -> defaults to inbox + custom folders; an explicit [] = "no own folders". allMailFolderIds: Record; // Email Display @@ -447,10 +451,9 @@ const DEFAULT_SETTINGS = { // Unified Mailbox enableUnifiedMailbox: false, - includeGroupInUnified: false, + includeGroupInUnified: true, + unifiedCrossAccount: false, - // All Mail view (gated) - enableAllMailView: false, allMailFolderIds: {} as Record, enableCrossUnreadView: false, @@ -635,7 +638,7 @@ export const useSettingsStore = create()( // (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced. enableUnifiedMailbox: state.enableUnifiedMailbox, includeGroupInUnified: state.includeGroupInUnified, - enableAllMailView: state.enableAllMailView, + unifiedCrossAccount: state.unifiedCrossAccount, allMailFolderIds: state.allMailFolderIds, enableCrossUnreadView: state.enableCrossUnreadView, enableCrossStarredView: state.enableCrossStarredView, @@ -897,9 +900,36 @@ export const useSettingsStore = create()( }), { name: 'settings-storage', - version: 5, - migrate: (persisted, version) => { - const state = persisted as Record; + version: 6, + migrate: migrateSettings, + onRehydrateStorage: () => { + return (state) => { + if (state) { + // Defensive: a legacy global array or any non-record value (e.g. + // synced from an older client) is coerced to an empty map so + // per-account consumers never see a non-record. + if (!isPlainRecord(state.allMailFolderIds)) { + state.allMailFolderIds = {}; + } + if (!isPlainRecord(state.preferredIdentityIds)) { + state.preferredIdentityIds = {}; + } + applyFontSize(state.fontSize); + applyDensity(state.density); + applyAnimations(state.animationsEnabled); + } + }; + }, + } + ) +); + +/** + * Versioned migration for persisted settings. Exported for tests. Mutates and + * returns the persisted record so each bump only needs to handle its own delta. + */ +export function migrateSettings(persisted: unknown, version: number): SettingsState { + const state = persisted as Record; if (version < 2 && state.listDensity) { state.density = state.listDensity; delete state.listDensity; @@ -925,29 +955,32 @@ export const useSettingsStore = create()( if (version < 5 || !isPlainRecord(state.allMailFolderIds)) { state.allMailFolderIds = {}; } - return state as unknown as SettingsState; - }, - onRehydrateStorage: () => { - return (state) => { - if (state) { - // Defensive: a legacy global array or any non-record value (e.g. - // synced from an older client) is coerced to an empty map so - // per-account consumers never see a non-record. - if (!isPlainRecord(state.allMailFolderIds)) { - state.allMailFolderIds = {}; - } - if (!isPlainRecord(state.preferredIdentityIds)) { - state.preferredIdentityIds = {}; - } - applyFontSize(state.fontSize); - applyDensity(state.density); - applyAnimations(state.animationsEnabled); + // v6: "All accounts" was reworked into the account-bounded "Unified + // Mailbox". The standalone __all_mail__ view (`enableAllMailView`) was + // folded into the unified "All mail" entry (`enableCrossAllView`), and a + // `unifiedCrossAccount` toggle now governs whether the views span every + // logged-in account. Existing users keep their current behaviour: + // - if any cross view was on, they were already cross-account -> keep it on + // - else if only standalone All Mail was on, enable the account-bounded + // unified "All mail" entry (folder selection carries over via allMailFolderIds) + if (version < 6) { + const hadCross = !!(state.enableCrossUnreadView || state.enableCrossStarredView || state.enableCrossAllView); + if (hadCross) { + state.unifiedCrossAccount = true; + } else if (state.enableAllMailView) { + state.enableUnifiedMailbox = true; + state.enableCrossAllView = true; + state.unifiedCrossAccount = false; } - }; - }, - } - ) -); + delete state.enableAllMailView; + if (typeof state.unifiedCrossAccount !== 'boolean') state.unifiedCrossAccount = false; + // The reworked unified mailbox spans the account's own folders plus its + // shared/group folders, so enable shared inclusion for every migrated + // configuration (matches the new-install default). + state.includeGroupInUnified = true; + } + return state as unknown as SettingsState; +} // Helper functions to apply settings to DOM function applyFontSize(size: FontSize) { From dc72122ed80394b38406bd4689bdb75203b1dd09 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:35:19 +0200 Subject: [PATCH 02/16] feat(unified-mailbox): enable search in the unified views Enable text AND advanced search in all Unified Mailbox views (the per-role mailboxes and the folder-selected All mail / Unread / Starred cross views). The search input was hard-disabled for every unified view; the store fan-out already supported text search. - page.tsx: the search text input and the advanced-filter toggle are enabled for all unified views (only the scheduled view stays disabled). Clear-search also restores a cross view (not just per-role). - Advanced filters now apply in cross views too: new advancedSearchCrossViewEmails ANDs the advanced filter (text + field conditions from buildJMAPFilter, built without an inMailbox clause) onto the cross-view membership. Per-role unified views keep using advancedSearchUnifiedEmails. Both honor the filter on the first page, on load-more, and on the folder-switch re-run. Fixes: an active Starred filter not applying after switching into a cross view, and the Unread filter in the Unread view returning nothing. - Search persistence on folder switch: an active search is kept and re-run in the target view, preserving advanced filters. handleMailboxSelect picks advancedSearch when filters are set (normal, per-role unified, and cross views, after setting the unified state), text searchEmails when only a query is set, and browses otherwise. The scheduled view is the only view that resets the search on enter (unavailable there; setScheduledView clears searchQuery + searchFilters). Account scope is intentionally left unrestricted in search (it already fanned out across all accounts); the per-view folder selection still applies via crossIncludedMailboxIds. --- FEATURES.md | 1 + app/(main)/[locale]/page.tsx | 45 ++++++++++++++++----- lib/__tests__/unified-mailbox-cross.test.ts | 26 ++++++++++++ lib/unified-mailbox.ts | 23 +++++++++++ stores/email-store.ts | 22 +++++++--- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/FEATURES.md b/FEATURES.md index 6a78de96..1043c7c1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -6,6 +6,7 @@ - Gmail-style threading with inline expansion and an optional conversation toggle - Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account - Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message +- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes - 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 diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 64947e0d..26352683 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1838,7 +1838,18 @@ export default function Home() { } const populated = await buildPopulatedUnifiedAccounts(); - await fetchUnifiedEmailsAction(populated, role); + // Keep an active search across the switch and re-run it in this view + // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. + if (client && (!isFilterEmpty(searchFilters) || searchQuery)) { + useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null }); + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(client); + } else { + await searchEmails(client, searchQuery); + } + } else { + await fetchUnifiedEmailsAction(populated, role); + } refreshUnifiedCounts(populated); return; } @@ -1860,7 +1871,18 @@ export default function Home() { } const populated = await buildPopulatedUnifiedAccounts(); - await fetchCrossViewAction(populated, view); + // Keep an active search across the switch and re-run it in this view + // (mirrors normal mailboxes), preserving advanced filters; otherwise browse. + if (client && (!isFilterEmpty(searchFilters) || searchQuery)) { + useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null }); + if (!isFilterEmpty(searchFilters)) { + await advancedSearch(client); + } else { + await searchEmails(client, searchQuery); + } + } else { + await fetchCrossViewAction(populated, view); + } refreshCrossCounts(populated); return; } @@ -2206,13 +2228,16 @@ export default function Home() { setSearchQuery(""); clearSearchFilters(); if (!client) return; - // In unified view the active "mailbox" is a virtual role, so refresh via - // the unified fan-out instead of fetchEmails. + // In unified view the active "mailbox" is a virtual role or cross view, so + // refresh via the unified fan-out instead of fetchEmails. if (isUnifiedView) { + const populated = await buildPopulatedUnifiedAccounts(); const role = useEmailStore.getState().unifiedRole; + const cross = useEmailStore.getState().crossView; if (role) { - const populated = await buildPopulatedUnifiedAccounts(); await fetchUnifiedEmailsAction(populated, role); + } else if (cross) { + await fetchCrossViewAction(populated, cross); } return; } @@ -2848,8 +2873,8 @@ export default function Home() { className={cn("ps-9 h-9", searchQuery && "pe-8")} data-search-input data-tour="search-input" - disabled={isUnifiedView || isScheduledView} - title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined} + disabled={isScheduledView} + title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined} /> {searchQuery && ( -
+

{t('new_message')}

{saveStatus === 'saving' && (
@@ -2136,6 +2136,7 @@ export function EmailComposer({