Skip to content

Feat/unified mailbox account scope#509

Open
hildebrandttk wants to merge 4 commits into
bulwarkmail:mainfrom
hildebrandttk:feat/unified-mailbox-account-scope
Open

Feat/unified mailbox account scope#509
hildebrandttk wants to merge 4 commits into
bulwarkmail:mainfrom
hildebrandttk:feat/unified-mailbox-account-scope

Conversation

@hildebrandttk

Copy link
Copy Markdown
Contributor

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) ==

  • lib/unified-mailbox.ts: UnifiedAccountClient gains crossIncludedMailboxIds.
    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).
  • stores/email-store.ts: buildUnifiedAccountClients() takes a new
    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).
  • app/(main)/[locale]/page.tsx: buildPopulatedUnifiedAccounts passes
    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 ==

  • The standalone all_mail virtual folder and its dedicated fetch/search/
    load-more branches are removed from stores/email-store.ts; ALL_MAIL_MAILBOX_ID
    is dropped from lib/jmap/types.ts.
  • Its per-account folder selection (allMailFolderIds) is retained and now narrows
    the unified All mail / Unread / Starred lists for the owning account.
  • components/email/thread-list-item.tsx: the source-folder column now keys only
    on isUnifiedView (the removed all_mail id no longer participates).
  • components/settings/layout-settings.tsx: the old "All Mail" toggle is gone; the
    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):

  • NEW unifiedCrossAccount (default false) - per-user opt-in to span all login
    accounts.
  • includeGroupInUnified default flips to true (shared inclusion is the defining
    trait of the account-bounded unified view).
  • enableAllMailView retired (kept only as a migration input).
  • enableCrossUnreadView / enableCrossStarredView / enableCrossAllView retained;
    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):

  • NEW feature gate unifiedCrossAccountEnabled, default FALSE: cross-account is an
    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.
  • allMailViewEnabled is deprecated and hidden from the admin UI; on policy load it
    is normalized forward into crossAllViewEnabled (config-manager.normalizePolicy)
    so admins who had standalone All Mail keep the unified All mail entry available.
  • The three cross-view gate labels are reworded from "All accounts: ..." to
    "Unified Mailbox: ...".

== 4. Sidebar header label (context-aware) ==

components/layout/sidebar.tsx shows:

  • "All accounts" when cross-account is active (user opt-in AND admin gate AND
    >1 connected account), and
  • "Unified Mailbox" when account-bounded (own + shared/group folders).

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 already
handled it. This commit turns it on:

  • Text search enabled for ALL unified views (page.tsx: disabled={isScheduledView}).
    searchEmails routes isUnifiedView+crossView -> searchCrossViewEmails and
    isUnifiedView+unifiedRole -> searchUnifiedEmails (both already present).
  • Clear-search now restores a cross view too (handleClearSearch previously only
    re-fetched a per-role view).
  • Search persistence on folder switch: an active search is kept and re-run in the
    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).
  • Advanced filters: enabled for the per-role unified mailboxes only - the store's
    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), and
    handleSearch never routes a cross view through advancedSearch.
  • Account scope is intentionally NOT restricted in search: the search fan-out
    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:

  • If any cross view was enabled before -> unifiedCrossAccount = true (these users
    were already cross-account; behaviour preserved).
  • Else if only the standalone All Mail view was enabled -> enable the unified
    mailbox + the All mail entry, account-bounded (folder selection carries over via
    allMailFolderIds, untouched).
  • includeGroupInUnified is set true for every migrated config (the reworked model
    spans own + shared folders).
  • enableAllMailView is deleted from the persisted state.
  • Fresh installs get account-bounded defaults.

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

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactor / code quality improvement
  • Chore / dependency update / CI change

Checklist

  • I have read the Contributing Guide
  • My code follows the project's code style and conventions
  • I have run npm run typecheck && npm run lint and there are no errors
  • The build passes (npm run build)
  • I have tested my changes locally
  • I have added or updated documentation if needed
  • I have updated translations (locales/) if my changes affect user-facing text
  • I have included screenshots or a screen recording for UI changes

Screenshots / Demo

image image image

Notes for Reviewers

…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.
@hildebrandttk hildebrandttk force-pushed the feat/unified-mailbox-account-scope branch from 498fb88 to 37d32d1 Compare June 27, 2026 09:34
…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.
@hildebrandttk hildebrandttk force-pushed the feat/unified-mailbox-account-scope branch from 7eee26d to 192a553 Compare June 28, 2026 19:19
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant