import { describe, it, expect } from 'vitest'; import { buildEmailSort, compareEmails, detectPreset, hasKeywordLevels, hasTextLevels, isDefaultOrder, keywordFirst, levelKeyword, orderForMailbox, orderKeywords, placeHeldRows, presetLevels, sanitizeSortLevels, MAX_SORT_LEVELS, type SortLevel, } from '../message-list-order'; import type { Email } from '../jmap/types'; const UNREAD_FIRST: SortLevel[] = [{ criterion: 'unread', direction: 'desc' }]; const STARRED_FIRST: SortLevel[] = [{ criterion: 'starred', direction: 'desc' }]; function makeEmail(overrides: Partial = {}): Email { return { id: overrides.id ?? Math.random().toString(36).slice(2), threadId: 't', mailboxIds: {}, keywords: {}, size: 100, receivedAt: '2026-01-01T00:00:00Z', hasAttachment: false, ...overrides, }; } describe('sanitizeSortLevels', () => { it('returns an empty list for anything that is not an array', () => { expect(sanitizeSortLevels(undefined)).toEqual([]); expect(sanitizeSortLevels('unread')).toEqual([]); expect(sanitizeSortLevels({ criterion: 'unread' })).toEqual([]); }); it('drops unknown criteria, defaults a missing direction and caps the list', () => { const levels = sanitizeSortLevels([ { criterion: 'unread' }, { criterion: 'importance', direction: 'desc' }, { criterion: 'from', direction: 'sideways' }, { criterion: 'size', direction: 'asc' }, { criterion: 'subject', direction: 'asc' }, ]); expect(levels).toEqual([ { criterion: 'unread', direction: 'desc' }, { criterion: 'from', direction: 'asc' }, { criterion: 'size', direction: 'asc' }, ]); expect(levels).toHaveLength(MAX_SORT_LEVELS); }); it('drops a tag level without a tag id and duplicate criteria', () => { expect(sanitizeSortLevels([ { criterion: 'tag', direction: 'desc' }, { criterion: 'tag', direction: 'desc', tagId: 'red' }, { criterion: 'tag', direction: 'desc', tagId: 'red' }, { criterion: 'tag', direction: 'asc', tagId: 'blue' }, { criterion: 'unread', direction: 'desc' }, { criterion: 'unread', direction: 'asc' }, ])).toEqual([ { criterion: 'tag', direction: 'desc', tagId: 'red' }, { criterion: 'tag', direction: 'asc', tagId: 'blue' }, { criterion: 'unread', direction: 'desc' }, ]); }); }); describe('keyword semantics', () => { it('maps the boolean criteria onto JMAP keywords', () => { expect(levelKeyword({ criterion: 'unread', direction: 'desc' })).toBe('$seen'); expect(levelKeyword({ criterion: 'starred', direction: 'desc' })).toBe('$flagged'); expect(levelKeyword({ criterion: 'tag', direction: 'desc', tagId: 'red' })).toBe('$label:red'); expect(levelKeyword({ criterion: 'receivedAt', direction: 'desc' })).toBeNull(); }); it('lists the keywords an order sorts on', () => { expect(orderKeywords([ { criterion: 'unread', direction: 'desc' }, { criterion: 'from', direction: 'asc' }, { criterion: 'tag', direction: 'desc', tagId: 'red' }, ])).toEqual(['$seen', '$label:red']); expect(orderKeywords([])).toEqual([]); }); it('"unread first" means messages WITHOUT $seen first', () => { expect(keywordFirst({ criterion: 'unread', direction: 'desc' })).toBe(false); expect(keywordFirst({ criterion: 'unread', direction: 'asc' })).toBe(true); expect(keywordFirst({ criterion: 'starred', direction: 'desc' })).toBe(true); expect(keywordFirst({ criterion: 'tag', direction: 'desc', tagId: 'x' })).toBe(true); }); it('knows when a keyword comparator is needed', () => { expect(hasKeywordLevels([])).toBe(false); expect(hasKeywordLevels([], true)).toBe(true); expect(hasKeywordLevels([{ criterion: 'size', direction: 'desc' }])).toBe(false); expect(hasKeywordLevels(UNREAD_FIRST)).toBe(true); }); }); describe('hasTextLevels', () => { // Only the server knows the collation of a sender or subject order. it('is true for any order with a sender or subject level', () => { expect(hasTextLevels([{ criterion: 'from', direction: 'asc' }])).toBe(true); expect(hasTextLevels([{ criterion: 'subject', direction: 'desc' }])).toBe(true); expect(hasTextLevels([...UNREAD_FIRST, { criterion: 'from', direction: 'asc' }])).toBe(true); }); it('is false for date, size and keyword orders', () => { expect(hasTextLevels([])).toBe(false); expect(hasTextLevels([...UNREAD_FIRST, ...STARRED_FIRST, { criterion: 'size', direction: 'desc' }])).toBe(false); expect(hasTextLevels([{ criterion: 'receivedAt', direction: 'asc' }, { criterion: 'sentAt', direction: 'desc' }])).toBe(false); }); }); describe('buildEmailSort', () => { it('is plain newest-first with no levels', () => { expect(buildEmailSort([])).toEqual([{ property: 'receivedAt', isAscending: false }]); }); it('follows the RFC 8621 polarity by default: has-keyword first is descending', () => { // RFC example: {someInThreadHaveKeyword, $flagged, isAscending: false} sorts flagged first. expect(buildEmailSort(STARRED_FIRST)).toEqual([ { property: 'hasKeyword', keyword: '$flagged', isAscending: false }, { property: 'receivedAt', isAscending: false }, ]); // Unread first = $seen absent first = $seen ascending. expect(buildEmailSort(UNREAD_FIRST)).toEqual([ { property: 'hasKeyword', keyword: '$seen', isAscending: true }, { property: 'receivedAt', isAscending: false }, ]); }); it('flips only the keyword comparators for an inverted server (Stalwart 0.16.8)', () => { expect(buildEmailSort(UNREAD_FIRST, { polarity: 'inverted' })).toEqual([ { property: 'hasKeyword', keyword: '$seen', isAscending: false }, { property: 'receivedAt', isAscending: false }, ]); expect(buildEmailSort([{ criterion: 'size', direction: 'asc' }], { polarity: 'inverted' })).toEqual([ { property: 'size', isAscending: true }, { property: 'receivedAt', isAscending: false }, ]); }); it('keeps pinned-first ahead of the configured order', () => { expect(buildEmailSort(UNREAD_FIRST, { pinnedFirst: true })).toEqual([ { property: 'hasKeyword', keyword: '$pinned', isAscending: false }, { property: 'hasKeyword', keyword: '$seen', isAscending: true }, { property: 'receivedAt', isAscending: false }, ]); expect(buildEmailSort([], { pinnedFirst: true, polarity: 'inverted' })[0]).toEqual( { property: 'hasKeyword', keyword: '$pinned', isAscending: true }, ); }); it('drops keyword comparators when the server cannot sort on them', () => { expect(buildEmailSort(UNREAD_FIRST, { pinnedFirst: true, keywordSortSupported: false })).toEqual([ { property: 'receivedAt', isAscending: false }, ]); }); it('does not add a second receivedAt comparator when the user ordered by date', () => { expect(buildEmailSort([ { criterion: 'from', direction: 'asc' }, { criterion: 'receivedAt', direction: 'asc' }, ])).toEqual([ { property: 'from', isAscending: true }, { property: 'receivedAt', isAscending: true }, ]); }); }); describe('compareEmails', () => { const unreadOld = makeEmail({ id: 'unread-old', receivedAt: '2026-01-01T00:00:00Z' }); const unreadNew = makeEmail({ id: 'unread-new', receivedAt: '2026-01-03T00:00:00Z' }); const readNewest = makeEmail({ id: 'read-newest', receivedAt: '2026-01-05T00:00:00Z', keywords: { $seen: true } }); const readMid = makeEmail({ id: 'read-mid', receivedAt: '2026-01-02T00:00:00Z', keywords: { $seen: true } }); it('is newest-first with no levels', () => { const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails([])); expect(sorted.map(e => e.id)).toEqual(['read-newest', 'unread-new', 'read-mid', 'unread-old']); }); it('puts unread first, each group newest first', () => { const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails(UNREAD_FIRST)); expect(sorted.map(e => e.id)).toEqual(['unread-new', 'unread-old', 'read-newest', 'read-mid']); }); it('honours "read first"', () => { const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails([{ criterion: 'unread', direction: 'asc' }])); expect(sorted.map(e => e.id)).toEqual(['read-newest', 'read-mid', 'unread-new', 'unread-old']); }); it('keeps pinned mail on top of any order', () => { const pinnedRead = makeEmail({ id: 'pinned', receivedAt: '2025-01-01T00:00:00Z', keywords: { $seen: true, $pinned: true } }); const sorted = [unreadNew, pinnedRead, readNewest].sort(compareEmails(UNREAD_FIRST, { pinnedFirst: true })); expect(sorted.map(e => e.id)).toEqual(['pinned', 'unread-new', 'read-newest']); }); it('sorts by starred, tag, sender, subject and size', () => { const a = makeEmail({ id: 'a', size: 10, from: [{ name: 'Zoe', email: 'z@x' }], subject: 'Re: beta', keywords: { '$label:red': true } }); const b = makeEmail({ id: 'b', size: 30, from: [{ name: '', email: 'alpha@x' }], subject: 'Fwd: alpha', keywords: { $flagged: true } }); const c = makeEmail({ id: 'c', size: 20, from: [{ name: 'Mia', email: 'm@x' }], subject: 'gamma' }); expect([a, b, c].sort(compareEmails(STARRED_FIRST)).map(e => e.id)).toEqual(['b', 'a', 'c']); expect([a, b, c].sort(compareEmails([{ criterion: 'tag', direction: 'desc', tagId: 'red' }])).map(e => e.id)).toEqual(['a', 'b', 'c']); expect([a, b, c].sort(compareEmails([{ criterion: 'from', direction: 'asc' }])).map(e => e.id)).toEqual(['b', 'c', 'a']); expect([a, b, c].sort(compareEmails([{ criterion: 'subject', direction: 'asc' }])).map(e => e.id)).toEqual(['b', 'a', 'c']); expect([a, b, c].sort(compareEmails([{ criterion: 'size', direction: 'desc' }])).map(e => e.id)).toEqual(['b', 'c', 'a']); }); it('puts a message without a sender or subject last in both directions, as Stalwart 0.16 and 1.0 do', () => { const named = makeEmail({ id: 'named', from: [{ name: 'Eve', email: 'eve@x.test' }], subject: 'Mango' }); const lower = makeEmail({ id: 'lower', from: [{ name: 'bea', email: 'bea@x.test' }], subject: 'apple pie' }); const bare = makeEmail({ id: 'bare', from: [], subject: undefined }); // A subject that is nothing but a reply prefix has an empty thread name. const onlyPrefix = makeEmail({ id: 'only-prefix', from: [{ name: '', email: '' }], subject: 'Re:', receivedAt: '2025-01-01T00:00:00Z' }); for (const criterion of ['from', 'subject'] as const) { expect([bare, named, onlyPrefix, lower].sort(compareEmails([{ criterion, direction: 'asc' }])).map(e => e.id)) .toEqual(['lower', 'named', 'bare', 'only-prefix']); expect([bare, lower, onlyPrefix, named].sort(compareEmails([{ criterion, direction: 'desc' }])).map(e => e.id)) .toEqual(['named', 'lower', 'bare', 'only-prefix']); } }); it('applies levels in priority order', () => { const starredOld = makeEmail({ id: 'starred-old', receivedAt: '2026-01-01T00:00:00Z', keywords: { $flagged: true, $seen: true } }); const sorted = [readNewest, starredOld, unreadNew].sort(compareEmails([ { criterion: 'unread', direction: 'desc' }, { criterion: 'starred', direction: 'desc' }, ])); expect(sorted.map(e => e.id)).toEqual(['unread-new', 'starred-old', 'read-newest']); }); it('sorts a held mail by its held keywords', () => { const justRead = makeEmail({ id: 'just-read', receivedAt: '2026-01-04T00:00:00Z', keywords: { $seen: true } }); const held = new Map([['just-read', {}]]); const sorted = [unreadOld, readNewest, justRead, unreadNew].sort(compareEmails(UNREAD_FIRST, { held })); expect(sorted.map(e => e.id)).toEqual(['just-read', 'unread-new', 'unread-old', 'read-newest']); }); }); describe('placeHeldRows', () => { const mail = (id: string, day: number, read: boolean, extra: Record = {}) => makeEmail({ id, receivedAt: `2026-01-0${day}T00:00:00Z`, keywords: read ? { $seen: true, ...extra } : { ...extra } }); it('moves a held mail back to where it sorted when it was opened', () => { // Server order after reading u2: the unread, then the read by date. const list = [mail('u3', 3, false), mail('u1', 1, false), mail('r4', 4, true), mail('u2', 2, true)]; const placed = placeHeldRows(list, UNREAD_FIRST, new Map([['u2', {}]])); expect(placed.map(e => e.id)).toEqual(['u3', 'u2', 'u1', 'r4']); }); it('leaves the other rows in their order', () => { const list = [mail('u3', 3, false), mail('u1', 1, false), mail('r4', 4, true), mail('r2', 2, true)]; const placed = placeHeldRows(list, UNREAD_FIRST, new Map([['r4', { $seen: true }]])); expect(placed.map(e => e.id)).toEqual(['u3', 'u1', 'r4', 'r2']); }); it('keeps pinned mail above a held one', () => { const list = [mail('p', 1, true, { $pinned: true }), mail('u1', 1, false), mail('r9', 9, true)]; const placed = placeHeldRows(list, UNREAD_FIRST, new Map([['r9', {}]])); expect(placed.map(e => e.id)).toEqual(['p', 'r9', 'u1']); }); it('does nothing for an order without keyword levels', () => { const list = [mail('a', 3, true), mail('b', 2, false)]; expect(placeHeldRows(list, [], new Map([['a', {}]]))).toBe(list); expect(placeHeldRows(list, UNREAD_FIRST, undefined)).toBe(list); }); }); describe('orderForMailbox', () => { it('applies to the Inbox only by default', () => { expect(orderForMailbox(UNREAD_FIRST, 'inbox', 'inbox')).toEqual(UNREAD_FIRST); expect(orderForMailbox(UNREAD_FIRST, 'inbox', 'archive')).toEqual([]); expect(orderForMailbox(UNREAD_FIRST, 'inbox', null)).toEqual([]); }); it('applies everywhere under the all-folders scope', () => { expect(orderForMailbox(UNREAD_FIRST, 'all', 'archive')).toEqual(UNREAD_FIRST); expect(orderForMailbox(UNREAD_FIRST, 'all', undefined)).toEqual(UNREAD_FIRST); expect(orderForMailbox([], 'all', 'inbox')).toEqual([]); }); }); describe('presets', () => { it('round-trips the simple presets', () => { expect(detectPreset(presetLevels('chronological'))).toBe('chronological'); expect(detectPreset(presetLevels('unread_first'))).toBe('unread_first'); expect(detectPreset(presetLevels('starred_first'))).toBe('starred_first'); expect(detectPreset(presetLevels('tagged_first', 'red'))).toBe('tagged_first'); expect(presetLevels('tagged_first')).toEqual([]); }); it('treats an explicit trailing newest-first level as part of the preset', () => { expect(detectPreset([...UNREAD_FIRST, { criterion: 'receivedAt', direction: 'desc' }])).toBe('unread_first'); expect(isDefaultOrder([{ criterion: 'receivedAt', direction: 'desc' }])).toBe(true); }); it('reports anything else as custom', () => { expect(detectPreset([{ criterion: 'unread', direction: 'asc' }])).toBe('custom'); expect(detectPreset([...UNREAD_FIRST, ...STARRED_FIRST])).toBe('custom'); expect(detectPreset([{ criterion: 'size', direction: 'desc' }])).toBe('custom'); }); });