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:
+4
-5
@@ -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`)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
||||
@@ -22,10 +24,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
||||
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
||||
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
|
||||
@@ -97,7 +97,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
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<HTMLDivElement, ThreadListItemPro
|
||||
: null;
|
||||
|
||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
||||
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
|
||||
const showSourceFolder = isUnifiedView && !!latestEmail.sourceFolder;
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||
{showAllMailMailbox && (
|
||||
<SidebarRow
|
||||
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('mailboxes.all_mail')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
|
||||
onClick={() => onMailboxSelect?.('__all_mail__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
||||
<div>
|
||||
<SidebarSectionHeader
|
||||
label={t("all_accounts")}
|
||||
label={t(crossAccountActive ? "all_accounts" : "unified_mailbox")}
|
||||
expanded={unifiedExpanded}
|
||||
onToggle={toggleUnified}
|
||||
isCollapsed={isCollapsed}
|
||||
|
||||
@@ -118,19 +118,26 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => 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() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && connectedAccountCount > 1 && unifiedCrossAccountAllowed && !isSettingHidden('unifiedCrossAccount') && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.cross_account.label')}
|
||||
description={t('unified_mailbox.cross_account.description')}
|
||||
locked={isSettingLocked('unifiedCrossAccount')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={unifiedCrossAccount}
|
||||
onChange={(v) => updateSetting('unifiedCrossAccount', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
|
||||
<SettingItem
|
||||
@@ -259,8 +281,9 @@ export function LayoutSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
|
||||
{enableUnifiedMailbox && anyCrossAllowed && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||
|
||||
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
|
||||
allowed && !isSettingHidden(setting) && (
|
||||
<SettingItem
|
||||
@@ -279,21 +302,9 @@ export function LayoutSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
|
||||
<SettingItem
|
||||
label={t('all_mail.label')}
|
||||
description={t('all_mail.description')}
|
||||
locked={isSettingLocked('enableAllMailView')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableAllMailView}
|
||||
onChange={(v) => updateSetting('enableAllMailView', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{allMailViewAllowed && enableAllMailView && (
|
||||
{enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
|
||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
writeFile(policyPath(), JSON.stringify({ features, restrictions: {} }, null, 2), 'utf-8');
|
||||
const readFeatures = async () =>
|
||||
JSON.parse(await readFile(policyPath(), 'utf-8')).features as Record<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
@@ -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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<string, unknown> | null = null;
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(policyPath, 'utf-8')) as Record<string, unknown>;
|
||||
} 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<string, unknown>)
|
||||
: 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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+6
-13
@@ -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__';
|
||||
|
||||
+22
-2
@@ -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 ?? ''));
|
||||
}
|
||||
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
+14
-11
@@ -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": {
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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": "پوشهای موجود نیست",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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}.",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(out.unifiedCrossAccount).toBe(false);
|
||||
expect(out.includeGroupInUnified).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+36
-86
@@ -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<string, unknown> {
|
||||
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<UnifiedAccountClient[]> {
|
||||
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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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);
|
||||
|
||||
+72
-39
@@ -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<string, string[]>;
|
||||
|
||||
// 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<string, string[]>,
|
||||
|
||||
enableCrossUnreadView: false,
|
||||
@@ -635,7 +638,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// (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<SettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
version: 5,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
if (version < 2 && state.listDensity) {
|
||||
state.density = state.listDensity;
|
||||
delete state.listDensity;
|
||||
@@ -925,29 +955,32 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user