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).
This commit is contained in:
@@ -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<UnifiedAccountClient[]> => {
|
||||
// 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=<id>` (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}
|
||||
|
||||
Reference in New Issue
Block a user