Feat/unified mailbox account scope#509
Open
hildebrandttk wants to merge 4 commits into
Open
Conversation
…oss-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).
Enable text AND advanced search in all Unified Mailbox views (the per-role mailboxes and the folder-selected All mail / Unread / Starred cross views). The search input was hard-disabled for every unified view; the store fan-out already supported text search. - page.tsx: the search text input and the advanced-filter toggle are enabled for all unified views (only the scheduled view stays disabled). Clear-search also restores a cross view (not just per-role). - Advanced filters now apply in cross views too: new advancedSearchCrossViewEmails ANDs the advanced filter (text + field conditions from buildJMAPFilter, built without an inMailbox clause) onto the cross-view membership. Per-role unified views keep using advancedSearchUnifiedEmails. Both honor the filter on the first page, on load-more, and on the folder-switch re-run. Fixes: an active Starred filter not applying after switching into a cross view, and the Unread filter in the Unread view returning nothing. - Search persistence on folder switch: an active search is kept and re-run in the target view, preserving advanced filters. handleMailboxSelect picks advancedSearch when filters are set (normal, per-role unified, and cross views, after setting the unified state), text searchEmails when only a query is set, and browses otherwise. The scheduled view is the only view that resets the search on enter (unavailable there; setScheduledView clears searchQuery + searchFilters). Account scope is intentionally left unrestricted in search (it already fanned out across all accounts); the per-view folder selection still applies via crossIncludedMailboxIds.
498fb88 to
37d32d1
Compare
…ove/read
In the unified All mail / Unread / Starred views, deleting (or moving /
marking read) a message from a shared/group folder left that folder's
sidebar counter at its old value.
Root cause: lib/unified-mailbox.ts decorates shared emails WITHOUT
namespacing their `mailboxIds`, so they carry the owner's bare JMAP ids,
while the shared mailbox is stored with a namespaced id (`${ownerId}:${origId}`)
and `isShared: true`. `emailInMailbox` only matched the namespaced `mailbox.id`
and disabled the `originalId` fallback for shared mailboxes, so no shared
email ever matched its folder and the counter math skipped it.
Match shared mailboxes via `originalId` too, scoped to the owning account
(`sourceAccountId === mailbox.accountId`) so a bare owner id can't collide
with another account's folder. This is the single matching helper used by all
counter paths (delete/move/markRead/spam), so they're all fixed at once.
Adds a regression test covering deletion of a shared-folder email in the
unified view.
7eee26d to
192a553
Compare
…e, background push
The unified-section sidebar badges (per-role unified folders + cross-view
All mail/Unread/Starred) failed to count down when messages were deleted/moved/
read from the unified views, and failed to count up for incoming mail - while
the underlying per-account folder counters updated correctly. Root cause: the
badges were a separate counter representation, recomputed only by a fresh server
fetch, completely decoupled from the optimistically-patched mailbox lists.
Three coordinated changes:
V1 - single source of truth: derive `unifiedCounts`/`crossUnreadCount` as a pure
live projection of `mailboxes` + `accountMailboxes` (the lists every mutation
already patches and push refreshes), over the last-known unified scope. A store
subscription re-projects whenever those lists change, so optimistic deletes and
push refreshes flow into the badges with no server round trip and no
eventual-consistency snap-back.
V3 - unified id space: searchEmails/advancedSearchEmails now namespace shared/
delegated mailboxIds (`${ownerId}:${id}`) like getEmails already did. The
cross-account views browse via advancedSearchEmails, so shared emails there
previously carried bare owner ids; now every fetch path is consistent and
emailInMailbox hits the `ids[mailbox.id]` fast path (originalId branches kept as
a defensive fallback). resolveSourceFolderName matches `m.id` first (also fixes
a latent missing source-folder name for shared emails).
Background push: bind push notifications for every connected login, not just the
active one - background accounts now drive the unified counters by rebuilding the
unified scope on their state changes. handleStateChange also refreshes the
mailbox list on a Mailbox change for ANY changed account key, so delegated
shared-folder activity arriving via the active client updates counters too.
Tests: unified-badge live projection on delete; client-level namespacing for
searchEmails/advancedSearchEmails (shared vs own account).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reworks 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 (the old behaviour) becomes an opt-in
sub-option instead of the default. The standalone per-account "All Mail" virtual
folder is folded into the unified All mail / Unread / Starred entries, and its
per-account folder selection now narrows those lists.
Default behaviour change: an account-bounded unified mailbox is the new default;
cross-account is something the user turns on (admin-gated). Existing users keep
their current behaviour via a settings migration (see "Migration").
Changes
Files: stores/settings-store.ts, stores/email-store.ts, lib/unified-mailbox.ts,
lib/jmap/types.ts, lib/admin/types.ts, lib/admin/config-manager.ts,
app/(main)/[locale]/page.tsx, app/(main)/admin/_tabs/policy.tsx,
components/layout/sidebar.tsx, components/settings/layout-settings.tsx,
components/email/thread-list-item.tsx, locales/*/common.json (20),
+ tests (lib/tests/unified-mailbox-cross.test.ts,
stores/tests/settings-store-all-mail.test.ts).
== 1. Scope: account-bounded by default (the core change) ==
getCrossIncludedMailboxes() (and thus getCrossUnreadTotal + the fan-out) honor
that per-account folder selection; when unset they fall back to the previous
role-exclusion default (inbox + custom folders).
scopeToClientAccountId option. When set, only that login account (plus the
shared owners reachable through its client) is built - this is the account
boundary. It also populates crossIncludedMailboxIds for personal accounts from
the user's allMailFolderIds selection (shared accounts stay unrestricted, so
all their folders are included).
scopeToClientAccountId = active account when cross-account is OFF, and nothing
(all accounts) when ON. Cross-account is only applied when the per-user opt-in
AND the admin capability gate are both true. The unified-counts effect re-runs
on account switch / toggle change.
The per-role unified mailboxes (Unified Inbox/Sent/Drafts/Trash/Archive/Junk) are
kept and obey the same scope.
== 2. Fold the standalone "All Mail" into the unified entries ==
load-more branches are removed from stores/email-store.ts; ALL_MAIL_MAILBOX_ID
is dropped from lib/jmap/types.ts.
the unified All mail / Unread / Starred lists for the owning account.
on isUnifiedView (the removed all_mail id no longer participates).
folder picker now sits under the Unified Mailbox group and appears once any of
the All mail / Unread / Starred entries is enabled.
== 3. Configuration (admin + user) ==
User settings (stores/settings-store.ts):
accounts.
trait of the account-bounded unified view).
they now gate the unified Unread / Starred / All mail entries (scope governed by
unifiedCrossAccount, not by these).
Admin policy (lib/admin/types.ts, config-manager.ts, policy.tsx):
admin opt-in capability. When off, the per-user toggle is hidden and the scope
is forced account-bounded at runtime (the per-user setting is AND-ed with this
gate). Existing cross-account installs are preserved by a one-shot migration
(see "Migration"). The default behaviour is account-bounded regardless,
because the per-user setting also defaults false.
is normalized forward into crossAllViewEnabled (config-manager.normalizePolicy)
so admins who had standalone All Mail keep the unified All mail entry available.
"Unified Mailbox: ...".
== 4. Sidebar header label (context-aware) ==
components/layout/sidebar.tsx shows:
>1 connected account), and
page.tsx computes the crossAccountActive flag and passes it as a prop.
== 5. Search in the unified views (commit 2, ae878b8) ==
The search input was hard-disabled for every unified view (page.tsx
disabled={isUnifiedView || isScheduledView}), although the store fan-out alreadyhandled it. This commit turns it on:
disabled={isScheduledView}).searchEmails routes isUnifiedView+crossView -> searchCrossViewEmails and
isUnifiedView+unifiedRole -> searchUnifiedEmails (both already present).
re-fetched a per-role view).
target view, preserving advanced filters where supported. handleMailboxSelect
picks advancedSearch when filters are set (normal mailboxes and per-role unified
views, after setting the unified state), text searchEmails when only a query is
set, and browses otherwise; cross views stay text-only. Previously the re-run
only honored the text query and silently dropped active advanced filters. The
scheduled view is the only view that resets search on enter (unavailable there;
setScheduledView clears searchQuery + searchFilters).
advancedSearch and loadMore already apply buildJMAPFilter via
advancedSearchUnifiedEmails (first page + pagination). Cross views stay
text-only: searchCrossViewEmails ignores the advanced filter, so a real
filter-aware cross query would need a dedicated helper (deferred). The advanced
toggle is gated
isScheduledView || (isUnifiedView && crossView), andhandleSearch never routes a cross view through advancedSearch.
already spans all accounts (no scopeToClientAccountId), and that is kept. The
per-view folder selection still applies via crossIncludedMailboxIds. (Note: a
cross-view browse is account-bounded while its search may return more; clearing
the search returns to the scoped browse list.)
== Migration ==
User settings - persist version 5 -> 6 (stores/settings-store.ts). The migrate()
body is extracted into an exported migrateSettings() for testability:
were already cross-account; behaviour preserved).
mailbox + the All mail entry, account-bounded (folder selection carries over via
allMailFolderIds, untouched).
spans own + shared folders).
Admin policy - one-shot, marker-guarded migration (migratePolicyUnifiedMailbox in
lib/admin/migrate.ts, run before configManager.load in instrumentation.node.ts).
Because unifiedCrossAccountEnabled defaults FALSE, existing installs that had any
cross view enabled would otherwise lose cross-account on upgrade (the gate AND-es
with the per-user setting). The migration enables the gate when crossUnread/
crossStarred/crossAll was active, writes it to policy.json, and drops a marker so
a later admin disable survives restarts. Skipped on read-only config dirs
(manual migration). The deprecated allMailViewEnabled (single-account, never
cross-account) does NOT trigger it; it is folded into crossAllViewEnabled by the
idempotent normalizePolicy on load.
Related Issues
#499 It's implemented for unified mailbox view including Shared folders, but not isolated
#497 unified mailbox view you can enabled a all mails view, that support search on that virtual folder.
Type of Change
Checklist
npm run typecheck && npm run lintand there are no errorsnpm run build)locales/) if my changes affect user-facing textScreenshots / Demo
Notes for Reviewers