Merge pull request #509 from hildebrandttk/feat/unified-mailbox-account-scope

Feat/unified mailbox account scope

Rework the sidebar "All accounts" into an account-bounded "Unified Mailbox"
by default, with cross-account merging as an opt-in (admin-gated) sub-option.
The standalone per-account "All Mail" virtual folder is folded into the unified
All mail / Unread / Starred entries.

Conflict resolution notes:
- stores/settings-store.ts: both main and this branch independently added a
  per-account default-identity (#507) migration at different versions (main v6,
  branch v7). Merged migration is version 7 using the refactored migrateSettings
  function; the unified-mailbox rework is guarded at `version < 7` so users who
  stopped at main's interim v6 identity bump still receive it, while the #507
  identity-map coercion stays at `version < 6` so their populated map is kept.
- stores/auth-store.ts: kept main's applyPreferredIdentity (superset with the
  pre-#507 legacy migration).
- stores/email-store.ts: removed the ALL_MAIL_MAILBOX_ID paths (folded into the
  unified views) while preserving main's plugin hooks (onSearchResults /
  onEmailsFetched); adopted advancedSearchCrossViewEmails for advanced cross-view
  search.
- components/settings/layout-settings.tsx: kept main's faviconUnreadBadge setting
  alongside the new unifiedCrossAccount toggle.
- integration/: union-merged the two independently-authored suites - branch suite
  is authoritative (matches new behavior) with main's shared-identity (#569) group
  infrastructure preserved.
- components/email/email-composer.tsx: dropped a duplicate data-testid attribute
  introduced by the auto-merge.
This commit is contained in:
Linus Rath
2026-07-16 19:57:51 +02:00
62 changed files with 2460 additions and 461 deletions
@@ -3,6 +3,7 @@ import { useEmailStore } from '../email-store';
import { useAuthStore } from '../auth-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { UnifiedAccountClient } from '@/lib/unified-mailbox';
// Regression coverage for issue #281: single-email actions performed in the
// unified inbox must be routed to the *email's own account* client, not the
@@ -54,6 +55,8 @@ function makeClient() {
toggleStar: vi.fn().mockResolvedValue(undefined),
moveEmail: vi.fn().mockResolvedValue(undefined),
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
} as unknown as IJMAPClient;
}
@@ -96,6 +99,9 @@ describe('unified-view single-email action routing (#281)', () => {
processingReadStatus: new Set(),
selectedEmail: null,
selectedEmailIds: new Set(),
unifiedScope: [],
unifiedCounts: [],
crossUnreadCount: 0,
emails: [
// 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 } }),
@@ -180,6 +186,96 @@ describe('unified-view single-email action routing (#281)', () => {
expect(activeClient.toggleStar).toHaveBeenCalledWith('email-shared', true, 'owner-x');
});
it('decrements a shared/group folder counter when deleting from the unified view', async () => {
// Real app: the active account's `mailboxes` includes its delegated shared
// folders (namespaced id + originalId + owner accountId). Unified-fetched
// shared emails carry the owner's BARE mailboxIds and sourceAccountId=owner.
// Regression: emailInMailbox missed these, so the shared folder's badge
// stayed at its old value after deleting in All mail / All unread.
useEmailStore.setState({
mailboxes: [
makeMailbox({ id: 'a-inbox', role: 'inbox', unreadEmails: 2, totalEmails: 5 }),
makeMailbox({
id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox',
role: 'inbox', isShared: true, accountId: 'owner-x',
unreadEmails: 4, totalEmails: 10,
}),
],
emails: [
makeEmail({
id: 'email-shared', accountId: 'owner-x',
sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x',
keywords: {}, // unread
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
}),
],
selectedEmailIds: new Set(['email-shared']),
});
await useEmailStore.getState().batchDelete(activeClient, true);
expect(activeClient.batchDeleteEmails).toHaveBeenCalledWith(['email-shared'], 'owner-x');
const shared = useEmailStore.getState().mailboxes.find(m => m.id === 'owner-x:x-inbox')!;
expect(shared.unreadEmails).toBe(3); // was 4
expect(shared.totalEmails).toBe(9); // was 10
});
it('decrements the unified-section badges when deleting from the unified view (live projection)', async () => {
// The unified-section badges (unifiedCounts / crossUnreadCount) must be a
// live projection of the per-account mailbox lists, NOT a stale server
// snapshot. Deleting a message in the unified view patches the folder's
// counter; the badge must follow in lockstep without a re-fetch.
const scope: UnifiedAccountClient[] = [
{
accountId: 'account-a', accountLabel: 'A', client: activeClient,
clientAccountId: 'account-a', jmapAccountId: 'account-a', isShared: false,
mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
},
{
accountId: 'owner-x', accountLabel: 'Shared', client: activeClient,
clientAccountId: 'account-a', jmapAccountId: 'owner-x', isShared: true,
mailboxes: [makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' })],
},
];
useEmailStore.setState({
mailboxes: [
makeMailbox({ id: 'a-inbox', role: 'inbox', unreadEmails: 2, totalEmails: 5 }),
makeMailbox({
id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox',
role: 'inbox', isShared: true, accountId: 'owner-x',
unreadEmails: 4, totalEmails: 10,
}),
],
emails: [
makeEmail({
id: 'email-shared', accountId: 'owner-x',
sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x',
keywords: {}, // unread
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
}),
],
selectedEmailIds: new Set(['email-shared']),
});
// Seed the badges from the scope (also stores unifiedScope).
useEmailStore.getState().refreshUnifiedCounts(scope);
useEmailStore.getState().refreshCrossCounts(scope);
const before = useEmailStore.getState();
expect(before.unifiedCounts.find(c => c.role === 'inbox')).toMatchObject({ unreadEmails: 6, totalEmails: 15 });
expect(before.crossUnreadCount).toBe(6);
await useEmailStore.getState().batchDelete(activeClient, true);
const after = useEmailStore.getState();
// Underlying folder counter dropped...
expect(after.mailboxes.find(m => m.id === 'owner-x:x-inbox')!.unreadEmails).toBe(3);
// ...and the unified-section badges followed via the live projection.
expect(after.unifiedCounts.find(c => c.role === 'inbox')).toMatchObject({ unreadEmails: 5, totalEmails: 14 });
expect(after.crossUnreadCount).toBe(5);
});
it('still uses the active/passed client outside unified view', async () => {
useEmailStore.setState({
isUnifiedView: false,
@@ -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);
});
});
@@ -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 preferredIdentityIds (issue #507)', () => {
beforeEach(() => {
@@ -55,4 +55,27 @@ describe('settings-store per-account preferredIdentityIds (issue #507)', () => {
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-9': 'a' });
});
});
describe('migrateSettings identity map', () => {
it('adds an empty preferredIdentityIds map for pre-v6 users', () => {
const out = migrateSettings({ allMailFolderIds: {} }, 6) as unknown as Record<string, unknown>;
expect(out.preferredIdentityIds).toEqual({});
});
it('coerces a non-record preferredIdentityIds to an empty map', () => {
const out = migrateSettings(
{ allMailFolderIds: {}, preferredIdentityIds: ['b'] },
7,
) as unknown as Record<string, unknown>;
expect(out.preferredIdentityIds).toEqual({});
});
it('preserves a valid per-account map across migration', () => {
const out = migrateSettings(
{ allMailFolderIds: {}, preferredIdentityIds: { 'acct-1': 'b' } },
7,
) as unknown as Record<string, unknown>;
expect(out.preferredIdentityIds).toEqual({ 'acct-1': 'b' });
});
});
});