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:
@@ -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