feat: cross-account "All accounts" views + full group/shared-account support

Add cross-account aggregate mail views and make group/shared (delegated)
accounts first-class in every aggregate view. (The unified mailbox, the "All
Mail" view, and "include group inboxes" already exist on main; this branch adds
the cross-account views and the shared-account correctness work.)

New views (admin-gated + per-user toggle, nested under Unified Mailbox):
- Cross-account "All accounts": All unread / All starred / All mail across every
  connected account, including shared/group folders. Each list labels the source
  folder of every message.

Source reference on aggregated emails (the core of the shared-account work):
- Replace the overloaded `accountId` with two explicit, always-set fields:
  `sourceClientAccountId` (the login the mail is reachable through) and
  `sourceAccountId` (the owning JMAP account). `accountId` stays display-only.
- Resolution is branch-free everywhere: pick the client by sourceClientAccountId,
  pass sourceAccountId as the JMAP accountId (no-op for personal), read the
  owner's mailbox list cached by JMAP id. No capability scan.

Shared/group-account correctness across all aggregate views:
- Route open (click + auto-fetch), thread/conversation open + reply-refresh,
  mark read, star, move, delete (account-scoped trash), archive (owner-routed
  createMailbox / fetchAccountMailboxes), and spam + undo via the source ref.
- Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails /
  createMailbox where missing.
- Fix local unread/total counter math for shared folders via emailInMailbox()
  (matches namespaced shared ids and bare own ids).
- Keep the unified/cross virtual selection on background mailbox refresh (no
  jump back to inbox after deleting in All Drafts/Junk).

Junk UX:
- In "All Junk" the spam action becomes "not spam" in the viewer, context menu,
  and list hover icons; undo routes shared mail back to its own inbox.

Admin:
- Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching
  per-user toggle (allMailViewEnabled clarified too).

i18n / docs / tests:
- locales (19): cross-view labels + descriptions and hover not_spam, translated
  in all shipped languages.
- FEATURES.md + README.md document the new views and group-account support.
- Tests for shared-account routing (single + batch + undoSpam), decoration, and
  unified-selection preservation.
This commit is contained in:
Stefan Hildebrandt
2026-06-23 19:16:22 +02:00
parent 3dd596ba50
commit a29c33b50a
41 changed files with 1491 additions and 226 deletions
@@ -64,10 +64,12 @@ describe('unified-view single-email action routing (#281)', () => {
activeClient = makeClient();
accountBClient = makeClient();
// Route account-b to its own client; account-a falls back to the active one.
// Route each login by its AccountEntry.id (the `sourceClientAccountId` key).
// account-a is the active login; account-b is a second direct login; the
// active login (account-a) also delegates access to the shared owner 'owner-x'.
useAuthStore.setState({
getClientForAccount: (id: string) =>
(id === 'account-b' ? accountBClient : undefined) as never,
(id === 'account-b' ? accountBClient : id === 'account-a' ? activeClient : undefined) as never,
} as never);
useEmailStore.setState({
@@ -76,18 +78,27 @@ describe('unified-view single-email action routing (#281)', () => {
viewingAccountId: null,
selectedMailbox: '',
mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
// Owner mailbox lists are cached by their JMAP id (`sourceAccountId`).
accountMailboxes: {
'account-a': [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
'account-b': [
makeMailbox({ id: 'b-inbox', role: 'inbox' }),
makeMailbox({ id: 'b-archive', name: 'Archive', role: 'archive' }),
],
// Shared owner reached through account-a's client.
'owner-x': [
makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }),
makeMailbox({ id: 'owner-x:x-trash', originalId: 'x-trash', name: 'Trash', role: 'trash', isShared: true, accountId: 'owner-x' }),
],
},
processingReadStatus: new Set(),
selectedEmail: null,
selectedEmailIds: new Set(),
emails: [
makeEmail({ id: 'email-b', accountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }),
// Second direct login: sourceClientAccountId === sourceAccountId === 'account-b'.
makeEmail({ id: 'email-b', accountId: 'account-b', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }),
// Shared/group source: reached via account-a's client, owned by 'owner-x'.
makeEmail({ id: 'email-shared', accountId: 'owner-x', sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
],
});
});
@@ -95,24 +106,36 @@ describe('unified-view single-email action routing (#281)', () => {
it('routes markAsRead to the emails account client', async () => {
await useEmailStore.getState().markAsRead(activeClient, 'email-b', true);
expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, undefined);
expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, 'account-b');
expect(activeClient.markAsRead).not.toHaveBeenCalled();
});
it('routes toggleStar to the emails account client', async () => {
it('routes toggleStar to the emails account client with the owner accountId', async () => {
await useEmailStore.getState().toggleStar(activeClient, 'email-b');
expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true);
expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true, 'account-b');
expect(activeClient.toggleStar).not.toHaveBeenCalled();
});
it('routes moveToMailbox to the emails account client with that accounts destination', async () => {
await useEmailStore.getState().moveToMailbox(activeClient, 'email-b', 'b-archive');
expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', undefined);
expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', 'account-b');
expect(activeClient.moveEmail).not.toHaveBeenCalled();
});
it('routes a shared/group email through the delegating login client + owner accountId', async () => {
await useEmailStore.getState().markAsRead(activeClient, 'email-shared', true);
// Reached via account-a's client (the active one), targeting the owner account.
expect(activeClient.markAsRead).toHaveBeenCalledWith('email-shared', true, 'owner-x');
expect(accountBClient.markAsRead).not.toHaveBeenCalled();
});
it('stars a shared/group email via the delegating client + owner accountId', async () => {
await useEmailStore.getState().toggleStar(activeClient, 'email-shared');
expect(activeClient.toggleStar).toHaveBeenCalledWith('email-shared', true, 'owner-x');
});
it('still uses the active/passed client outside unified view', async () => {
useEmailStore.setState({
isUnifiedView: false,
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import type { Mailbox } from '@/lib/jmap/types';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
return {
@@ -266,4 +267,41 @@ describe('email-store folder management', () => {
expect(useEmailStore.getState().error).toBe('Role update failed');
});
});
// Regression: a background fetchMailboxes (e.g. push-driven after deleting
// drafts in "All Drafts") must not reset a virtual unified/cross-view selection
// to the inbox, which would jump the user out of the view they're in.
describe('fetchMailboxes selection preservation', () => {
it('keeps a unified-view selection (e.g. All Drafts) on background refresh', async () => {
useEmailStore.setState({
mailboxes: [inbox, sent, trash, custom],
selectedMailbox: UNIFIED_MAILBOX_IDS.drafts,
isUnifiedView: true,
});
// Fresh list (not initial load) that does NOT contain the virtual id.
const client = makeMockClient({
getAllMailboxes: vi.fn().mockResolvedValue([inbox, sent, trash, custom]),
});
await useEmailStore.getState().fetchMailboxes(client);
expect(useEmailStore.getState().selectedMailbox).toBe(UNIFIED_MAILBOX_IDS.drafts);
});
it('still falls back to inbox when a real selection no longer exists', async () => {
useEmailStore.setState({
mailboxes: [inbox, sent, trash, custom],
selectedMailbox: 'custom-1',
isUnifiedView: false,
});
// custom-1 is gone from the refreshed list.
const client = makeMockClient({
getAllMailboxes: vi.fn().mockResolvedValue([inbox, sent, trash]),
});
await useEmailStore.getState().fetchMailboxes(client);
expect(useEmailStore.getState().selectedMailbox).toBe('inbox-1');
});
});
});