Files
native/src/lib/__tests__/thread-utils.test.ts
T
Linus Rath 2e26ff7b64 fix: report refused tags and re-check the sort order after a server change
Stalwart 1.0 refuses keywords over 128 bytes and more than 100
keywords on one message, per message in Email/set `notUpdated`; a
read-only shared folder refuses any keyword on every version. The app
stopped a large selection at the first refused batch, "mark folder as
read" ignored refusals, and the list's swipe, bulk and tag actions let
the rejection go unhandled, so nothing was said. The keyword sort
polarity verdict was stored per server address for a week, so a server
reinstalled as 1.0 (which sorts as the RFC says, where 0.16.8-0.16.22
invert) kept the old reading.

- Email/set writes send every batch before reporting the first refused
  message, and "mark folder as read" marks the rest, then reports the
  refusal. The list shows "Tagging failed" or "Failed to update email"
  for swipe, bulk and tag actions, and the viewer's automatic
  mark-as-read puts the message back to unread when it is refused.
- The sort polarity verdict is keyed by the server fingerprint as well
  (stored as `keyword-sort-polarity:v2`), so a replaced or upgraded
  server is probed afresh. An upgrade that leaves the capabilities
  alone, such as 0.16.22 to 0.16.23, still waits out the week.
- Tests pin that Stalwart 1.0's `$hasattachment` / `$hasnoattachment`
  never show as tags or as tags to recover; the app lists no other
  keywords, and tag ids stay within 128 bytes by construction.
2026-09-25 20:59:19 +02:00

113 lines
4.7 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
collapseThreads,
expandThreadSelection,
getEmailTagIds,
getThreadTagIds,
tagIdFromKeyword,
threadKeyOf,
rowKeyOf,
} 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();
});
it('shows no tag for the attachment keywords of Stalwart 1.0', () => {
expect(getEmailTagIds({ $hasattachment: true, '$label:work': true })).toEqual(['work']);
expect(getEmailTagIds({ $hasnoattachment: true })).toEqual([]);
expect(tagIdFromKeyword('$hasattachment')).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']);
});
it('works on row keys, so two accounts\' same ids stay apart (#1082)', () => {
const own = [email('a', 't1', '2026-01-01T00:00:00Z'), email('b', 't1', '2026-01-03T00:00:00Z')]
.map((e) => ({ ...e, jmapAccountId: 'c' }));
const team = [email('a', 't1', '2026-01-02T00:00:00Z')].map((e) => ({ ...e, jmapAccountId: 'team' }));
const rows = [...own, ...team];
expect(rowKeyOf(team[0])).toBe('team:a');
expect(rowKeyOf(emails[0])).toBe('a');
expect(expandThreadSelection(['c:b'], rows, false)).toEqual(['c:a', 'c:b']);
expect(expandThreadSelection(['team:a'], rows, false)).toEqual(['team:a']);
});
});