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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user