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:
@@ -397,4 +397,53 @@ describe('JMAPClient resilience', () => {
|
||||
).rejects.toThrow('Failed to fetch blob: 404');
|
||||
});
|
||||
});
|
||||
|
||||
// #281 V3: every email fetch path must namespace mailboxIds for shared/
|
||||
// delegated accounts (`${ownerId}:${id}`) so they line up with the store's
|
||||
// namespaced shared-mailbox ids. searchEmails/advancedSearchEmails are the
|
||||
// cross-view (All mail / Unread / Starred) browse paths and previously did not.
|
||||
describe('shared-account mailboxId namespacing', () => {
|
||||
function queryAndGet(email: Record<string, unknown>) {
|
||||
return {
|
||||
methodResponses: [
|
||||
['Email/query', { total: 1, ids: ['e1'] }, '0'],
|
||||
['Email/get', { list: [email] }, '1'],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
it('advancedSearchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
|
||||
const client = await createConnectedClient(); // primary acct-1
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.advancedSearchEmails({ inMailbox: 'owner-x:x-inbox' }, 'owner-x');
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
|
||||
expect(emails[0].mailboxIds['x-inbox']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('searchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
|
||||
const client = await createConnectedClient();
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.searchEmails('hello', undefined, 'owner-x');
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
|
||||
});
|
||||
|
||||
it('leaves own-account mailboxIds untouched (no foreign accountId)', async () => {
|
||||
const client = await createConnectedClient(); // primary acct-1
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } })),
|
||||
);
|
||||
|
||||
const { emails } = await client.advancedSearchEmails({ inMailbox: 'inbox' });
|
||||
|
||||
expect(emails[0].mailboxIds).toEqual({ inbox: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
buildCrossFilter,
|
||||
getCrossUnreadTotal,
|
||||
fetchCrossViewEmails,
|
||||
advancedSearchCrossViewEmails,
|
||||
resolveSourceFolderName,
|
||||
type UnifiedAccountClient,
|
||||
} from '@/lib/unified-mailbox';
|
||||
@@ -42,6 +43,26 @@ describe('getCrossIncludedMailboxes', () => {
|
||||
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
|
||||
expect(ids).toEqual(['inbox', 'projects']);
|
||||
});
|
||||
|
||||
it('honors an explicit crossIncludedMailboxIds selection (folder picker)', () => {
|
||||
const account = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined), mb('archive', 'archive')],
|
||||
// user picked inbox + archive, excluded projects - overrides role exclusion
|
||||
crossIncludedMailboxIds: ['inbox', 'archive'],
|
||||
});
|
||||
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
|
||||
expect(ids).toEqual(['inbox', 'archive']);
|
||||
});
|
||||
|
||||
it('an empty selection yields no folders', () => {
|
||||
const account = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined)],
|
||||
crossIncludedMailboxIds: [],
|
||||
});
|
||||
expect(getCrossIncludedMailboxes(account)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCrossFilter', () => {
|
||||
@@ -86,6 +107,22 @@ describe('getCrossUnreadTotal', () => {
|
||||
});
|
||||
expect(getCrossUnreadTotal([a, b])).toBe(10);
|
||||
});
|
||||
|
||||
it('counts only the selected folders when crossIncludedMailboxIds is set; shared accounts stay unrestricted', () => {
|
||||
// personal account narrowed to inbox only (projects excluded by the picker)
|
||||
const personal = makeAccount({
|
||||
accountId: 'a',
|
||||
mailboxes: [mb('inbox', 'inbox', 3), mb('projects', undefined, 4)],
|
||||
crossIncludedMailboxIds: ['inbox'],
|
||||
});
|
||||
// shared account unrestricted -> role-exclusion default (inbox + custom)
|
||||
const shared = makeAccount({
|
||||
accountId: 'owner',
|
||||
isShared: true,
|
||||
mailboxes: [mb('ns:inbox', 'inbox', 5, 'orig-inbox'), mb('ns:team', undefined, 2, 'orig-team'), mb('ns:junk', 'junk', 9, 'orig-junk')],
|
||||
});
|
||||
expect(getCrossUnreadTotal([personal, shared])).toBe(3 + 5 + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSourceFolderName', () => {
|
||||
@@ -167,3 +204,28 @@ describe('fetchCrossViewEmails', () => {
|
||||
expect(result.errors.get('bad')).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advancedSearchCrossViewEmails', () => {
|
||||
it('ANDs the advanced filter onto the cross-view membership', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||
|
||||
await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0);
|
||||
|
||||
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||
expect(filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses only the membership filter when the extra filter is empty', async () => {
|
||||
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||
|
||||
await advancedSearchCrossViewEmails([a], 'all', {}, 50, 0);
|
||||
|
||||
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||
expect(filter).toEqual({ inMailbox: 'inbox' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user