Files
native/src/lib/__tests__/thread-utils.test.ts
T
Linus Rath ee50418bac fix: search the shared accounts' folders too with "All folders" #1082
"All folders" sent its Email/query only to the account of the open
folder, so mail in a shared (group) account the same login reaches never
showed up: "zephyr" found the 3 own messages and missed the 5 in the team
inbox, although that inbox sits right there in the sidebar.

The search now asks the own account and every shared account with
folders in the sidebar, like the webmail's `searchAcrossAccounts`:

- one request carries Email/query, Email/get and Thread/get for each
  account (`queryEmailPagesAcrossAccounts`), and the pages are merged
  under the list order
- each row is stamped with its JMAP account (`Email.jmapAccountId`), so
  opening a hit opens it in that account and read, star, tag, archive,
  delete, spam and move act there, filing into that account's own
  Archive, Trash or Junk; one undo restores rows in every account
- "load more" continues each account from its own rows on screen rather
  than one shared offset, which would skip messages of the account with
  more hits
- an account that fails leaves the others' hits on screen with a notice
- a push for any account re-runs the search
- list keys, thread grouping and conversation sizes are scoped by
  account, as JMAP ids are only unique per account
- deleting a hit judges Trash and Junk by the hit's own folders, not the
  open folder, both for the move and for the permanent-delete confirm

A search scoped to one folder still asks only that folder's account.
2026-09-24 20:05:38 +02:00

94 lines
3.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
collapseThreads,
expandThreadSelection,
getEmailTagIds,
getThreadTagIds,
tagIdFromKeyword,
threadKeyOf,
} from '../thread-utils';
import type { Email } from '../../api/types';
function email(id: string, threadId: string, receivedAt: string, keywords: Record<string, boolean> = {}): Email {
return {
id, threadId, receivedAt, keywords, mailboxIds: {}, size: 0, hasAttachment: false,
};
}
describe('tag ids', () => {
it('reads both the $label: and legacy $color: prefixes once', () => {
expect(getEmailTagIds({ '$label:work': true, '$color:work': true, '$label:todo': true, $seen: true, '$label:off': false }))
.toEqual(['work', 'todo']);
expect(getEmailTagIds(undefined)).toEqual([]);
});
it('unions the tags of a whole thread', () => {
expect(getThreadTagIds([
email('a', 't', '2026-01-01T00:00:00Z', { '$label:work': true }),
email('b', 't', '2026-01-02T00:00:00Z', { '$label:todo': true, '$label:work': true }),
])).toEqual(['work', 'todo']);
});
it('extracts the tag id from a keyword', () => {
expect(tagIdFromKeyword('$label:work')).toBe('work');
expect(tagIdFromKeyword('$color:work')).toBe('work');
expect(tagIdFromKeyword('$seen')).toBeNull();
expect(tagIdFromKeyword('$label:')).toBeNull();
});
});
describe('collapseThreads', () => {
const oldest = email('a', 't1', '2026-01-01T00:00:00Z');
const newest = email('b', 't1', '2026-01-03T00:00:00Z');
const other = email('c', 't2', '2026-01-02T00:00:00Z');
it('represents a thread by its newest message even in an ascending list', () => {
const rows = collapseThreads([oldest, other, newest], false);
expect(rows.map((e) => e.id)).toEqual(['b', 'c']);
});
it('keeps the position of the first message the list showed', () => {
const rows = collapseThreads([other, oldest, newest], false);
expect(rows.map((e) => e.id)).toEqual(['c', 'b']);
});
it('leaves every message as its own row when threading is off', () => {
expect(collapseThreads([oldest, other, newest], true).map((e) => e.id)).toEqual(['a', 'c', 'b']);
});
it("keeps two accounts' threads with the same id apart (#1082)", () => {
// Stalwart hands out per-account counters, so an "All folders" list can
// hold an own and a team thread that share an id.
const own = { ...email('a', 't1', '2026-01-01T00:00:00Z'), jmapAccountId: 'c' };
const team = { ...email('a', 't1', '2026-01-02T00:00:00Z'), jmapAccountId: 'team' };
expect(collapseThreads([team, own], false)).toEqual([team, own]);
expect(threadKeyOf(own, false)).toBe('c:t1');
expect(threadKeyOf(team, true)).toBe('team:a');
expect(threadKeyOf(email('a', 't1', '2026-01-01T00:00:00Z'), false)).toBe('t1');
});
it('puts threads with a pinned message first', () => {
const pinned = email('d', 't3', '2025-12-01T00:00:00Z', { $pinned: true });
expect(collapseThreads([other, oldest, pinned], false).map((e) => e.id)).toEqual(['d', 'c', 'a']);
expect(collapseThreads([other, oldest, pinned], false, { pinnedFirst: false }).map((e) => e.id)).toEqual(['c', 'a', 'd']);
});
});
describe('expandThreadSelection', () => {
const emails = [
email('a', 't1', '2026-01-01T00:00:00Z'),
email('b', 't1', '2026-01-03T00:00:00Z'),
email('c', 't2', '2026-01-02T00:00:00Z'),
];
it('expands a representative to every loaded message of its thread', () => {
expect(expandThreadSelection(['b'], emails, false)).toEqual(['a', 'b']);
expect(expandThreadSelection(['b', 'c'], emails, false)).toEqual(['a', 'b', 'c']);
});
it('passes ids through when threading is off or the id is unknown', () => {
expect(expandThreadSelection(['b'], emails, true)).toEqual(['b']);
expect(expandThreadSelection(['zzz'], emails, false)).toEqual(['zzz']);
});
});