Files
SRCmail/lib/__tests__/unified-mailbox-cross.test.ts
T
Stefan Hildebrandt 7c221c4a4a feat(unified-mailbox): account-bounded Unified Mailbox with opt-in cross-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).
2026-07-11 21:14:34 +02:00

206 lines
7.8 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import {
getCrossIncludedMailboxes,
buildCrossFilter,
getCrossUnreadTotal,
fetchCrossViewEmails,
resolveSourceFolderName,
type UnifiedAccountClient,
} from '@/lib/unified-mailbox';
const mb = (id: string, role: string | undefined, unread = 0, originalId?: string): Mailbox =>
({ id, name: id, role, unreadEmails: unread, totalEmails: 0, originalId } as unknown as Mailbox);
const makeAccount = (
over: Partial<UnifiedAccountClient> & { accountId: string },
clientImpl: Partial<IJMAPClient> = {},
): UnifiedAccountClient => ({
accountLabel: over.accountId,
mailboxes: [],
client: clientImpl as unknown as IJMAPClient,
clientAccountId: over.accountId,
jmapAccountId: over.accountId,
...over,
});
describe('getCrossIncludedMailboxes', () => {
it('excludes junk/sent/archive/trash/drafts, keeps inbox + custom folders', () => {
const account = makeAccount({
accountId: 'a',
mailboxes: [
mb('inbox', 'inbox'),
mb('projects', undefined),
mb('junk', 'junk'),
mb('sent', 'sent'),
mb('archive', 'archive'),
mb('trash', 'trash'),
mb('drafts', 'drafts'),
],
});
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', () => {
it('all → single inMailbox for one folder', () => {
expect(buildCrossFilter('all', ['m1'])).toEqual({ inMailbox: 'm1' });
});
it('all → OR of inMailbox for multiple folders', () => {
expect(buildCrossFilter('all', ['m1', 'm2'])).toEqual({
operator: 'OR',
conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }],
});
});
it('unread → AND(membership, notKeyword $seen)', () => {
expect(buildCrossFilter('unread', ['m1', 'm2'])).toEqual({
operator: 'AND',
conditions: [
{ operator: 'OR', conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }] },
{ notKeyword: '$seen' },
],
});
});
it('starred → AND(membership, hasKeyword $flagged)', () => {
expect(buildCrossFilter('starred', ['m1'])).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'm1' }, { hasKeyword: '$flagged' }],
});
});
});
describe('getCrossUnreadTotal', () => {
it('sums unread across included folders of every account, ignoring excluded roles', () => {
const a = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox', 3), mb('proj', undefined, 2), mb('junk', 'junk', 50)],
});
const b = makeAccount({
accountId: 'b',
mailboxes: [mb('inbox', 'inbox', 5), mb('sent', 'sent', 99)],
});
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', () => {
const emailIn = (ids: string[]): Email =>
({ mailboxIds: Object.fromEntries(ids.map((id) => [id, true])) } as unknown as Email);
it('returns the name of the folder the email is in (personal account)', () => {
const boxes = [mb('inbox', 'inbox'), mb('proj', undefined)];
expect(resolveSourceFolderName(emailIn(['proj']), boxes)).toBe('proj');
});
it('matches shared mailboxes by originalId (email keyed by owner-side id)', () => {
// shared mailbox: namespaced store id, but email.mailboxIds uses originalId
const shared = { id: 'owner:inbox', role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId: 'orig-inbox', name: 'Team Inbox' } as unknown as Mailbox;
expect(resolveSourceFolderName(emailIn(['orig-inbox']), [shared])).toBe('Team Inbox');
});
it('returns undefined when no known folder contains the email', () => {
expect(resolveSourceFolderName(emailIn(['unknown']), [mb('inbox', 'inbox')])).toBeUndefined();
});
});
describe('fetchCrossViewEmails', () => {
it('merges + date-sorts across accounts and stamps account info', async () => {
const clientA = {
advancedSearchEmails: vi.fn().mockResolvedValue({
emails: [{ id: 'a1', receivedAt: '2026-01-01T10:00:00Z' } as Email],
total: 1,
hasMore: false,
}),
};
const clientB = {
advancedSearchEmails: vi.fn().mockResolvedValue({
emails: [{ id: 'b1', receivedAt: '2026-01-02T10:00:00Z' } as Email],
total: 1,
hasMore: true,
}),
};
const a = makeAccount({ accountId: 'a', accountLabel: 'A', mailboxes: [mb('inbox', 'inbox')] }, clientA);
const b = makeAccount({ accountId: 'b', accountLabel: 'B', mailboxes: [mb('inbox', 'inbox')] }, clientB);
const result = await fetchCrossViewEmails([a, b], 'all', 50, 0);
expect(result.emails.map((e) => e.id)).toEqual(['b1', 'a1']); // newest first
expect(result.emails[0].accountId).toBe('b');
expect(result.emails[1].accountLabel).toBe('A');
expect(result.total).toBe(2);
expect(result.hasMore).toBe(true);
});
it('resolves shared folders via originalId + owner accountId', async () => {
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
const shared = makeAccount(
{ accountId: 'owner-1', accountLabel: 'Shared', isShared: true, mailboxes: [mb('ns:inbox', 'inbox', 0, 'orig-inbox')] },
{ advancedSearchEmails },
);
await fetchCrossViewEmails([shared], 'unread', 50, 0);
const [filter, accountId] = advancedSearchEmails.mock.calls[0];
expect(accountId).toBe('owner-1');
// filter membership uses the originalId, not the namespaced id
expect(JSON.stringify(filter)).toContain('orig-inbox');
expect(JSON.stringify(filter)).not.toContain('ns:inbox');
});
it('collects per-account errors without failing the whole fan-out', async () => {
const ok = makeAccount(
{ accountId: 'ok', mailboxes: [mb('inbox', 'inbox')] },
{ advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [{ id: 'x', receivedAt: '2026-01-01T00:00:00Z' } as Email], total: 1, hasMore: false }) },
);
const bad = makeAccount(
{ accountId: 'bad', mailboxes: [mb('inbox', 'inbox')] },
{ advancedSearchEmails: vi.fn().mockRejectedValue(new Error('boom')) },
);
const result = await fetchCrossViewEmails([ok, bad], 'all', 50, 0);
expect(result.emails.map((e) => e.id)).toEqual(['x']);
expect(result.errors.get('bad')).toBe('boom');
});
});