Feat/unified mailbox account scope#509
Merged
rathlinus merged 16 commits intoJul 16, 2026
Merged
Conversation
hildebrandttk
force-pushed
the
feat/unified-mailbox-account-scope
branch
2 times, most recently
from
June 28, 2026 19:19
7eee26d to
192a553
Compare
…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.
…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.
…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).
) The default sender identity (`preferredPrimaryId`) lived only in the browser-local `identity-storage` Zustand store and was never written to the server-side synced settings. As a result the choice was lost when clearing site data or switching browsers, and never appeared in the exported settings JSON. Persist the default identity in the synced settings store, keyed per account (`preferredIdentityIds: Record<accountId, identityId>`), mirroring the existing per-account `allMailFolderIds`. Per-account keying is required because JMAP identity ids are account-scoped and would otherwise collide across accounts / the unified mailbox. - settings-store: add `preferredIdentityIds` to state, defaults, export (so it shows in exported JSON), import (with a non-record guard), rehydrate coercion, and a v6->v7 migration. - auth-store: add `applyPreferredIdentity()`, invoked in every `loadFromServer().finally()` (login / OAuth / SSO / switch / restore) so the synced default reorders the active account's identities once server settings load (the composer defaults From to identities[0]). - identity-manager-modal: the star action also writes the choice to the synced per-account map, triggering server sync + export inclusion. - identity-store: keep `preferredPrimaryId` in local persist as a sync-off fallback; synced settings are the durable cross-device source of truth. - tests: per-account independence, export/import round-trip, non-record import guard, and v6->v7 migration.
Stalwart's JMAP EventSource only pushes StateChange for the session's *primary* account — a background change in a shared/delegated (secondary) account is never pushed — so the shared folder's counters, and the unified/All-Mail badge that aggregates them, went stale until a full reload. (Other *login* accounts already update live because each login has its own SSE.) Extend the client's state poll to every account in the session: - buildStatePollingRequest emits a Mailbox/Email `get` per account, with the accountId encoded in the callId (`mbx:<id>` / `eml:<id>`); - checkForStateChanges / fetchCurrentStates key polling state per account and report a per-account `changed` map, which handleStateChange already treats as "some mailbox changed" and refetches the full (own + delegated) mailbox list from — the badge is a live projection over that list; - a slow (20s) secondary-account poll runs alongside SSE (paused while hidden) so shared counters stay current between focus events.
Add an end-to-end integration harness that runs the webmail against a real Stalwart mail server in Docker and drives it with Playwright, focused on the mail/folder synchronisation behaviour (unread/total counters, folder-list sync, account-scoped Unified Mailbox) that the unified-mailbox work touches. - integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol, submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP). - Helpers: dependency-free SMTP submit client, JMAP client for seeding / inspecting server state, and page helpers (login, add/switch account, locale-independent folder-counter reads). - Specs: login, single-account sync (receive/read/move/delete/folder-create/ burst) and multi-account (per-account isolation + cross-account Unified Inbox aggregation + background-account delivery). 12 tests, all green. - Add focused data-testid hooks to the mail UI (folder rows + counters, email list items, account switcher, composer) for stable selectors. - Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
Extend the integration suite (now 22 tests) to cover: - All Mail view (04): single-account merge of Inbox + custom folders with Junk excluded, and cross-account aggregation across every logged-in account. - Message actions from the list context menu (05): mark read/unread, delete (→ Trash), mark-as-spam (→ Junk) and not-spam round-trip, verified on both the UI counters/row state and the server mailbox the message ends up in. - Shared/delegated folders (06): a delegated folder (+ Trash/Junk) shared alice→carol; the shared folder renders with its counter, and read/unread/ delete/spam performed there land correctly (server-verified). Hooks added: data-testid on context-menu delete/spam/read-unread items (via a testId prop on ContextMenuItem), data-shared on folder rows, and testId/data-expanded on sidebar section headers to drive the Shared section. Observations surfaced by the suite (asserted server-side / with a reconcile): - mark-as-spam doesn't optimistically decrement the *source* counter the way delete does; a visibility reconcile settles it. - shared *destination* counters (shared Trash/Junk) don't refresh live — forceSync reconciles the active account only, not shared accounts.
Add draft and shared-folder-move coverage (suite now 31 tests). Findings are asserted server-side or pinned with test.fail where the UI is incomplete. Drafts (07): - multiple recipients (committed and typed-but-uncommitted) persist, and the draft reopens via the continue-draft button; - a server-created draft (with $draft) shows the continue-draft button; - a changed sender identity is saved to the draft on the server; - KNOWN BUG (test.fail): reopening a draft resets the From selector to the default identity instead of the one the draft was saved with. Shared-folder moves (08): - shared -> shared (same owner) moves work in both directions (server-verified); - KNOWN LIMITATION (test.fail): cross-account moves (own account <-> shared folder) don't relocate the message — the Move-to submenu offers the target but clicking it is a no-op. Hooks added: composer From select + save-status, viewer edit-draft button, context-menu "Move to" submenu + per-target testids (testId on ContextMenuSubMenu). Helpers: JMAP identities/createDraft/sharing, composer drive + move-via-submenu. README documents the findings.
Add 09-live-counters: a background-login account updates the unified counter live (no reconcile), and a shared-folder change reconciles the All-Mail counter on focus. Document the shared-account counter behaviour in the README.
… messages Blobs are scoped per JMAP account, but the attachment download/preview path always used the active account's client and accountId. Opening a message from a different account in the unified / All-Mail view and downloading (or previewing) an attachment therefore 404'd against the active account. Route the blob fetch to the message's source instead: - resolveBlobSource() picks the owning login's client (getClientForAccount(sourceClientAccountId)) and the owner accountId (sourceAccountId) for delegated/shared blobs, in the unified view; - handleDownloadAttachment + the attachment-preview handlers use it; - downloadBlob / fetchBlobAsObjectUrl / fetchBlobArrayBuffer gain an accountId param (getBlobDownloadUrl/fetchBlob already had one). Adds 10-attachments: an attachment on another account's All-Mail message downloads with the correct bytes (verified to fail without the routing).
…sages Extend the cross-account blob routing beyond download/preview to every blob fetch in the message viewer, so a message opened from a different account in the unified / All-Mail view renders and exports correctly instead of 404ing against the active account: - inline cid: images, drag-to-desktop, attachment thumbnails, the "download all" zip bundle, and the S/MIME / TNEF / embedded-rfc822 blob reads now use a resolved blobClient (getClientForAccount(sourceClientAccountId)) and the owner blobAccountId (sourceAccountId), computed once from the open message's source; - fetchBlobAsObjectUrl / fetchBlobArrayBuffer / fetchBlob calls pass the accountId (the client methods gained the param in the previous commit); - non-cross-account behaviour is unchanged (blobClient === active client). Extends 10-attachments with an inline-image case (verified to fall back to the placeholder without the routing). The SMTP helper can now send multipart/related inline images.
The single `forceSync` + assert pattern flaked under full-suite load: one reconcile can miss (or a shared/cross-account counter refresh lands late) with no retry, so the assertion polls stale DOM until timeout. The flake moved between reconcile-dependent tests (server-side move, spam source drain, unified / shared counters) run to run. - Add expectFolderCountsSynced(): nudges a reconcile (visibilitychange -> checkForStateChanges) before *every* poll, so a missed reconcile is retried for the full window. Compares only the provided unread/total fields. - Use it for the reconcile-dependent counter checks in 02 (move/delete), 03 (multi-account isolation + unified aggregation), 04 (All Mail), 05 (spam source), 06 (shared folders); drop the now-redundant standalone forceSync. Pure live-push assertions (incoming/burst, background-login-live) keep the plain helpers so they still prove push works. - The spam->not-spam round-trip could stall the Junk badge reconcile even with retries under load; assert the optimistic list removal + authoritative server round-trip (out of Junk, back in Inbox) instead of the badge. Validated with two back-to-back full-suite runs: 35 passed each.
…main rebase Rebasing feat/unified-mailbox onto main hit deep, divergent conflicts in the mail-view/settings area (main added its own All-Mail + RTL refactor + a username-keyed bulwarkmail#507 identity impl). Post-rebase reconciliation: - re-apply the cross-account blob routing to the message viewer (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle download) on main's restructured file — every fetch goes through blobClient/blobAccountId derived from the message's source account; - drop the duplicate `preferredIdentityIds` declaration that both main (username-keyed) and the branch (accountId-keyed) introduced — the branch's account-scoped map is kept, matching the resolved modal/store logic. tsc + eslint clean; unified-mailbox unit tests pass (settings-store all-mail / preferred-identity, unified-mailbox-cross, jmap-client-resilience, migrate-policy).
The unified-mailbox rework added settings.appearance.unified_mailbox.
cross_account.{label,description} and sidebar.unified_mailbox, and dropped
the legacy settings.appearance.all_mail.{label,description}, in en and every
other locale except Hebrew (he) and Slovak (sk). Bring he/sk in line so the
translations-completeness test passes (no missing/extra keys vs en).
vitest was collecting the dockerized integration Playwright specs (run via `npm run test:integration`) and the untracked examples/ sample code, which fail under the vitest runner. Exclude both so `npm test` only runs the unit suite.
hildebrandttk
force-pushed
the
feat/unified-mailbox-account-scope
branch
from
July 11, 2026 19:16
a67ba4c to
4a3394c
Compare
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