mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
44c77b26kept the server's order for sender and subject sorted lists. That is right for Stalwart 1.0, which sorts lowercased byte-order keys, but 0.16 compares the raw bytes, capitals first, so on every 0.16 server a sender list read "Adam, Carla, Eve, bea, dave, zed". - keep the server's order only for accounts that list `urn:ietf:params:jmap:principals:owner` (every 1.0 account, never 0.16); elsewhere pages and thread groups are re-sorted case-insensitively, as before44c77b26- the client comparator puts a message without a sender or subject last in both directions, as both versions do Expanding a thread marked it read in the login's own account instead of the shared or group account it lives in, so Stalwart answered notFound or marked an unrelated message with the same id, and sinced7004b38the thread also stayed unread. The write now names the owning account. Moving mail between category tabs had the same fault: it took a listed message's display-only account reference, and now routes each message like the other keyword writes.
543 lines
21 KiB
TypeScript
543 lines
21 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
groupEmailsByThread,
|
|
sortThreadGroups,
|
|
getThreadParticipants,
|
|
mergeThreadEmails,
|
|
getEmailTagId,
|
|
getEmailTagIds,
|
|
getThreadTagId,
|
|
getThreadTagIds,
|
|
threadKeyFor,
|
|
threadIdFromKey,
|
|
toggleTagKeywords,
|
|
} from '../thread-utils';
|
|
import type { Email, ThreadGroup } from '../jmap/types';
|
|
|
|
const makeEmail = (overrides: Partial<Email> = {}): Email => ({
|
|
id: 'email-1',
|
|
threadId: 'thread-1',
|
|
mailboxIds: { inbox: true },
|
|
keywords: { $seen: true },
|
|
size: 1000,
|
|
receivedAt: '2024-01-15T10:00:00Z',
|
|
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
|
subject: 'Test Subject',
|
|
hasAttachment: false,
|
|
...overrides,
|
|
});
|
|
|
|
describe('groupEmailsByThread', () => {
|
|
it('groups emails by threadId', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', threadId: 'thread-1' }),
|
|
makeEmail({ id: 'e2', threadId: 'thread-1' }),
|
|
makeEmail({ id: 'e3', threadId: 'thread-2' }),
|
|
];
|
|
const groups = groupEmailsByThread(emails);
|
|
expect(groups).toHaveLength(2);
|
|
expect(groups.find(g => g.threadId === 'thread-1')!.emailCount).toBe(2);
|
|
expect(groups.find(g => g.threadId === 'thread-2')!.emailCount).toBe(1);
|
|
});
|
|
|
|
it('sorts emails within group by receivedAt descending', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', threadId: 'thread-1', receivedAt: '2024-01-10T10:00:00Z' }),
|
|
makeEmail({ id: 'e2', threadId: 'thread-1', receivedAt: '2024-01-15T10:00:00Z' }),
|
|
makeEmail({ id: 'e3', threadId: 'thread-1', receivedAt: '2024-01-12T10:00:00Z' }),
|
|
];
|
|
const group = groupEmailsByThread(emails)[0];
|
|
expect(group.emails[0].id).toBe('e2');
|
|
expect(group.emails[1].id).toBe('e3');
|
|
expect(group.emails[2].id).toBe('e1');
|
|
});
|
|
|
|
it('sets latestEmail to the newest email', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'old', threadId: 'thread-1', receivedAt: '2024-01-01T00:00:00Z' }),
|
|
makeEmail({ id: 'new', threadId: 'thread-1', receivedAt: '2024-06-01T00:00:00Z' }),
|
|
];
|
|
expect(groupEmailsByThread(emails)[0].latestEmail.id).toBe('new');
|
|
});
|
|
|
|
it('calculates participantNames from unique senders', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
|
makeEmail({ id: 'e2', from: [{ name: 'Bob', email: 'bob@example.com' }] }),
|
|
makeEmail({ id: 'e3', from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
|
];
|
|
const group = groupEmailsByThread(emails)[0];
|
|
expect(group.participantNames).toEqual(['Alice', 'Bob']);
|
|
});
|
|
|
|
it('detects hasUnread when an email lacks $seen', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: {} }),
|
|
];
|
|
expect(groupEmailsByThread(emails)[0].hasUnread).toBe(true);
|
|
});
|
|
|
|
it('detects hasStarred when an email has $flagged', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: { $seen: true, $flagged: true } }),
|
|
];
|
|
expect(groupEmailsByThread(emails)[0].hasStarred).toBe(true);
|
|
});
|
|
|
|
it('detects hasAttachment', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', hasAttachment: false }),
|
|
makeEmail({ id: 'e2', hasAttachment: true }),
|
|
];
|
|
expect(groupEmailsByThread(emails)[0].hasAttachment).toBe(true);
|
|
});
|
|
|
|
it('detects hasAnswered when an email has $answered', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: { $seen: true, $answered: true } }),
|
|
];
|
|
expect(groupEmailsByThread(emails)[0].hasAnswered).toBe(true);
|
|
});
|
|
|
|
it('detects hasForwarded when an email has $forwarded', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: { $seen: true, $forwarded: true } }),
|
|
];
|
|
expect(groupEmailsByThread(emails)[0].hasForwarded).toBe(true);
|
|
});
|
|
|
|
it('returns empty array for empty input', () => {
|
|
expect(groupEmailsByThread([])).toEqual([]);
|
|
});
|
|
|
|
it('returns empty array for null/undefined input', () => {
|
|
expect(groupEmailsByThread(null as unknown as Email[])).toEqual([]);
|
|
expect(groupEmailsByThread(undefined as unknown as Email[])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('sortThreadGroups', () => {
|
|
const makeGroup = (threadId: string, receivedAt: string, hasPinned = false): ThreadGroup => ({
|
|
threadId,
|
|
threadKey: threadId,
|
|
emails: [makeEmail({ receivedAt })],
|
|
latestEmail: makeEmail({ receivedAt }),
|
|
participantNames: ['A'],
|
|
hasUnread: false,
|
|
hasStarred: false,
|
|
hasPinned,
|
|
hasAttachment: false,
|
|
hasAnswered: false,
|
|
hasForwarded: false,
|
|
emailCount: 1,
|
|
});
|
|
|
|
it('sorts groups by latestEmail.receivedAt descending', () => {
|
|
const groups = [
|
|
makeGroup('old', '2024-01-01T00:00:00Z'),
|
|
makeGroup('new', '2024-06-01T00:00:00Z'),
|
|
];
|
|
const sorted = sortThreadGroups(groups);
|
|
expect(sorted[0].threadId).toBe('new');
|
|
expect(sorted[1].threadId).toBe('old');
|
|
});
|
|
|
|
it('keeps pinned threads on top regardless of date', () => {
|
|
const groups = [
|
|
makeGroup('newest', '2024-06-01T00:00:00Z'),
|
|
makeGroup('old-pinned', '2024-01-01T00:00:00Z', true),
|
|
makeGroup('mid', '2024-03-01T00:00:00Z'),
|
|
];
|
|
const sorted = sortThreadGroups(groups);
|
|
expect(sorted.map(g => g.threadId)).toEqual(['old-pinned', 'newest', 'mid']);
|
|
});
|
|
|
|
it('mirrors a configured list order instead of re-sorting by date (#718)', () => {
|
|
// makeEmail defaults to $seen, so the unread group needs bare keywords.
|
|
const unreadOld = { ...makeGroup('unread-old', '2024-01-01T00:00:00Z'), hasUnread: true };
|
|
unreadOld.emails = [makeEmail({ id: 'u', receivedAt: '2024-01-01T00:00:00Z', keywords: {} })];
|
|
const readNew = makeGroup('read-new', '2024-06-01T00:00:00Z');
|
|
const sorted = sortThreadGroups([readNew, unreadOld], [{ criterion: 'unread', direction: 'desc' }]);
|
|
expect(sorted.map(g => g.threadId)).toEqual(['unread-old', 'read-new']);
|
|
});
|
|
|
|
it('places a thread where its best-ranked email sorts, not its latest', () => {
|
|
// An old unread reply keeps the thread in the unread block even though
|
|
// the newest email in it has been read.
|
|
const mixed = makeGroup('mixed', '2024-06-01T00:00:00Z');
|
|
mixed.emails = [
|
|
makeEmail({ id: 'newest-read', receivedAt: '2024-06-01T00:00:00Z', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'old-unread', receivedAt: '2024-02-01T00:00:00Z', keywords: {} }),
|
|
];
|
|
const readNewer = makeGroup('read-newer', '2024-07-01T00:00:00Z');
|
|
const sorted = sortThreadGroups([readNewer, mixed], [{ criterion: 'unread', direction: 'desc' }]);
|
|
expect(sorted.map(g => g.threadId)).toEqual(['mixed', 'read-newer']);
|
|
});
|
|
|
|
it('keeps pinned threads on top of a configured order', () => {
|
|
const pinnedRead = makeGroup('pinned-read', '2024-01-01T00:00:00Z', true);
|
|
const unread = makeGroup('unread', '2024-06-01T00:00:00Z');
|
|
unread.emails = [makeEmail({ id: 'u', receivedAt: '2024-06-01T00:00:00Z', keywords: {} })];
|
|
const sorted = sortThreadGroups([unread, pinnedRead], [{ criterion: 'unread', direction: 'desc' }]);
|
|
expect(sorted.map(g => g.threadId)).toEqual(['pinned-read', 'unread']);
|
|
});
|
|
|
|
it('detects hasPinned from the $pinned keyword', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: { $seen: true, '$pinned': true } }),
|
|
];
|
|
expect(groupEmailsByThread(emails)[0].hasPinned).toBe(true);
|
|
});
|
|
|
|
it('keeps the fetched order of a sender order, as Stalwart 1.0 collates it', () => {
|
|
// The server's byte order puts "zoe" before "émile" and the message
|
|
// without a sender last; a localeCompare re-sort would swap the first two.
|
|
const fetched = [
|
|
makeEmail({ id: 'z', threadId: 'tz', from: [{ name: 'Zoe', email: 'zoe@example.org' }] }),
|
|
makeEmail({ id: 'e1', threadId: 'te', from: [{ name: 'Émile', email: 'emile@example.org' }] }),
|
|
makeEmail({ id: 'n', threadId: 'tn', from: [] }),
|
|
makeEmail({ id: 'e2', threadId: 'te', from: [{ name: 'Émile', email: 'emile@example.org' }], receivedAt: '2024-02-01T00:00:00Z' }),
|
|
];
|
|
const order = [{ criterion: 'from' as const, direction: 'asc' as const }];
|
|
expect(sortThreadGroups(groupEmailsByThread(fetched), order, { keepServerTextOrder: true }).map(g => g.threadId))
|
|
.toEqual(['tz', 'te', 'tn']);
|
|
});
|
|
|
|
it('still lifts pinned threads above a subject order on Stalwart 1.0, and otherwise keeps the fetched order', () => {
|
|
// Same subject everywhere: a client re-sort would fall back to newest first.
|
|
const groups = [
|
|
makeGroup('first', '2024-01-01T00:00:00Z'),
|
|
makeGroup('second', '2024-06-01T00:00:00Z'),
|
|
makeGroup('pinned', '2024-03-01T00:00:00Z', true),
|
|
];
|
|
const sorted = sortThreadGroups(groups, [{ criterion: 'subject', direction: 'asc' }], { keepServerTextOrder: true });
|
|
expect(sorted.map(g => g.threadId)).toEqual(['pinned', 'first', 'second']);
|
|
});
|
|
|
|
it('re-sorts a sender order case-insensitively on Stalwart 0.16, the empty sender last', () => {
|
|
// 0.16 pages come in raw byte order, capitals first; the loaded pages are
|
|
// sorted as one list, as before 1.0.
|
|
const fetched = [
|
|
makeEmail({ id: 'a', threadId: 'ta', from: [{ name: 'Adam', email: 'adam@x.test' }] }),
|
|
makeEmail({ id: 'c', threadId: 'tc', from: [{ name: 'Carla', email: 'carla@x.test' }] }),
|
|
makeEmail({ id: 'n', threadId: 'tn', from: [] }),
|
|
makeEmail({ id: 'b', threadId: 'tb', from: [{ name: 'bea', email: 'bea@x.test' }] }),
|
|
makeEmail({ id: 'd', threadId: 'td', from: [{ name: 'dave', email: 'dave@x.test' }] }),
|
|
];
|
|
const order = [{ criterion: 'from' as const, direction: 'asc' as const }];
|
|
expect(sortThreadGroups(groupEmailsByThread(fetched), order).map(g => g.threadId))
|
|
.toEqual(['ta', 'tb', 'tc', 'td', 'tn']);
|
|
expect(sortThreadGroups(groupEmailsByThread(fetched), [{ criterion: 'from', direction: 'desc' }]).map(g => g.threadId))
|
|
.toEqual(['td', 'tc', 'tb', 'ta', 'tn']);
|
|
});
|
|
});
|
|
|
|
describe('getThreadParticipants', () => {
|
|
it('extracts unique sender names', () => {
|
|
const emails = [
|
|
makeEmail({ from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
|
makeEmail({ from: [{ name: 'Bob', email: 'bob@example.com' }] }),
|
|
makeEmail({ from: [{ name: 'Alice', email: 'alice@example.com' }] }),
|
|
];
|
|
expect(getThreadParticipants(emails)).toEqual(['Alice', 'Bob']);
|
|
});
|
|
|
|
it('respects maxNames limit', () => {
|
|
const emails = [
|
|
makeEmail({ from: [{ name: 'A', email: 'a@x.com' }] }),
|
|
makeEmail({ from: [{ name: 'B', email: 'b@x.com' }] }),
|
|
makeEmail({ from: [{ name: 'C', email: 'c@x.com' }] }),
|
|
];
|
|
expect(getThreadParticipants(emails, 2)).toEqual(['A', 'B']);
|
|
});
|
|
|
|
it('uses email prefix when name is empty', () => {
|
|
const emails = [
|
|
makeEmail({ from: [{ name: '', email: 'charlie@example.com' }] }),
|
|
];
|
|
expect(getThreadParticipants(emails)).toEqual(['charlie']);
|
|
});
|
|
});
|
|
|
|
describe('mergeThreadEmails', () => {
|
|
it('merges new emails without duplicating existing ones', () => {
|
|
const existing: ThreadGroup = {
|
|
threadId: 'thread-1',
|
|
threadKey: 'thread-1',
|
|
emails: [
|
|
makeEmail({ id: 'e1', receivedAt: '2024-01-10T00:00:00Z' }),
|
|
makeEmail({ id: 'e2', receivedAt: '2024-01-09T00:00:00Z' }),
|
|
],
|
|
latestEmail: makeEmail({ id: 'e1', receivedAt: '2024-01-10T00:00:00Z' }),
|
|
participantNames: ['Alice'],
|
|
hasUnread: false,
|
|
hasStarred: false,
|
|
hasPinned: false,
|
|
hasAttachment: false,
|
|
hasAnswered: false,
|
|
hasForwarded: false,
|
|
emailCount: 2,
|
|
};
|
|
const fetched = [
|
|
makeEmail({ id: 'e2', receivedAt: '2024-01-09T00:00:00Z' }),
|
|
makeEmail({ id: 'e3', receivedAt: '2024-01-11T00:00:00Z', from: [{ name: 'Bob', email: 'bob@example.com' }] }),
|
|
];
|
|
const merged = mergeThreadEmails(existing, fetched);
|
|
expect(merged.emailCount).toBe(3);
|
|
expect(merged.emails.map(e => e.id)).toEqual(['e3', 'e1', 'e2']);
|
|
});
|
|
|
|
it('updates thread metadata after merge', () => {
|
|
const existing: ThreadGroup = {
|
|
threadId: 'thread-1',
|
|
threadKey: 'thread-1',
|
|
emails: [makeEmail({ id: 'e1', keywords: { $seen: true }, hasAttachment: false })],
|
|
latestEmail: makeEmail({ id: 'e1' }),
|
|
participantNames: ['Alice'],
|
|
hasUnread: false,
|
|
hasStarred: false,
|
|
hasPinned: false,
|
|
hasAttachment: false,
|
|
hasAnswered: false,
|
|
hasForwarded: false,
|
|
emailCount: 1,
|
|
};
|
|
const fetched = [
|
|
makeEmail({
|
|
id: 'e2',
|
|
receivedAt: '2024-06-01T00:00:00Z',
|
|
keywords: { $flagged: true },
|
|
hasAttachment: true,
|
|
from: [{ name: 'Bob', email: 'bob@example.com' }],
|
|
}),
|
|
];
|
|
const merged = mergeThreadEmails(existing, fetched);
|
|
expect(merged.latestEmail.id).toBe('e2');
|
|
expect(merged.hasUnread).toBe(true);
|
|
expect(merged.hasStarred).toBe(true);
|
|
expect(merged.hasAttachment).toBe(true);
|
|
expect(merged.participantNames).toContain('Bob');
|
|
});
|
|
});
|
|
|
|
describe('getEmailTagIds', () => {
|
|
it('gathers every tag set on the message', () => {
|
|
expect(getEmailTagIds({ '$label:red': true, '$label:work': true, $seen: true }))
|
|
.toEqual(['red', 'work']);
|
|
});
|
|
|
|
it('reads the legacy prefix alongside the current one', () => {
|
|
expect(getEmailTagIds({ '$label:red': true, '$color:blue': true })).toEqual(['red', 'blue']);
|
|
});
|
|
|
|
it('reports a tag written under both prefixes once', () => {
|
|
expect(getEmailTagIds({ '$label:red': true, '$color:red': true })).toEqual(['red']);
|
|
});
|
|
|
|
it('ignores keywords set to false', () => {
|
|
expect(getEmailTagIds({ '$label:red': false, '$label:work': true })).toEqual(['work']);
|
|
});
|
|
|
|
it('is empty for an untagged message or none at all', () => {
|
|
expect(getEmailTagIds({ $seen: true })).toEqual([]);
|
|
expect(getEmailTagIds(undefined)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// The result is written as the message's whole keyword map, where RFC 8621
|
|
// allows only true values: a removed tag must be missing, not false.
|
|
describe('toggleTagKeywords', () => {
|
|
it('adds a tag without disturbing the other keywords', () => {
|
|
expect(toggleTagKeywords({ $seen: true, '$label:red': true }, 'work'))
|
|
.toEqual({ $seen: true, '$label:red': true, '$label:work': true });
|
|
});
|
|
|
|
it('takes a set tag off by leaving its key out, under either prefix', () => {
|
|
const next = toggleTagKeywords({ $seen: true, '$label:red': true, '$color:red': true, '$label:work': true }, 'red');
|
|
expect(next).toEqual({ $seen: true, '$label:work': true });
|
|
expect(Object.values(next).every((value) => value === true)).toBe(true);
|
|
});
|
|
|
|
it('clears every tag, whatever the prefix, and keeps the rest', () => {
|
|
expect(toggleTagKeywords({ $flagged: true, '$label:red': true, '$color:blue': true }, null))
|
|
.toEqual({ $flagged: true });
|
|
});
|
|
|
|
it('drops keywords the local copy only marks false', () => {
|
|
// Marking a message unread or unstarred leaves these behind locally.
|
|
expect(toggleTagKeywords({ $seen: false, $flagged: false, '$label:red': false }, 'red'))
|
|
.toEqual({ '$label:red': true });
|
|
});
|
|
|
|
it('keeps the server-set attachment keyword Stalwart 1.0 reports', () => {
|
|
expect(toggleTagKeywords({ $hasattachment: true }, 'work')).toEqual({ $hasattachment: true, '$label:work': true });
|
|
});
|
|
|
|
it('starts from nothing when the message has no keywords', () => {
|
|
expect(toggleTagKeywords(undefined, 'work')).toEqual({ '$label:work': true });
|
|
expect(toggleTagKeywords(undefined, null)).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('getEmailTagId', () => {
|
|
it('returns label from $label: keyword', () => {
|
|
expect(getEmailTagId({ '$label:red': true, $seen: true })).toBe('red');
|
|
});
|
|
|
|
it('returns label from legacy $color: keyword', () => {
|
|
expect(getEmailTagId({ '$color:red': true, $seen: true })).toBe('red');
|
|
});
|
|
|
|
it('returns null when no color keyword', () => {
|
|
expect(getEmailTagId({ $seen: true, $flagged: true })).toBeNull();
|
|
});
|
|
|
|
it('returns null for undefined keywords', () => {
|
|
expect(getEmailTagId(undefined)).toBeNull();
|
|
});
|
|
|
|
it('ignores keywords set to false', () => {
|
|
expect(getEmailTagId({ '$label:red': false } as unknown as Record<string, boolean>)).toBeNull();
|
|
});
|
|
|
|
it('prefers $label: over $color: when both exist', () => {
|
|
expect(getEmailTagId({ '$label:blue': true, '$color:red': true })).toBe('blue');
|
|
});
|
|
|
|
it('handles custom keyword ids', () => {
|
|
expect(getEmailTagId({ '$label:my-custom-tag': true })).toBe('my-custom-tag');
|
|
});
|
|
|
|
it('returns null for empty keywords object', () => {
|
|
expect(getEmailTagId({})).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getThreadTagId', () => {
|
|
it('returns first color found across thread emails', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
|
];
|
|
expect(getThreadTagId(emails)).toBe('blue');
|
|
});
|
|
|
|
it('returns null when no emails have color tags', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: { $flagged: true } }),
|
|
];
|
|
expect(getThreadTagId(emails)).toBeNull();
|
|
});
|
|
|
|
it('returns first tag from earliest tagged email', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
|
makeEmail({ id: 'e2', keywords: { '$label:blue': true } }),
|
|
];
|
|
expect(getThreadTagId(emails)).toBe('red');
|
|
});
|
|
|
|
it('returns legacy tag from thread emails', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { $seen: true } }),
|
|
makeEmail({ id: 'e2', keywords: { '$color:green': true } }),
|
|
];
|
|
expect(getThreadTagId(emails)).toBe('green');
|
|
});
|
|
|
|
it('returns null for empty email array', () => {
|
|
expect(getThreadTagId([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('getThreadTagIds', () => {
|
|
it('gathers the tags of every message in the thread', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
|
makeEmail({ id: 'e2', keywords: { '$label:blue': true, '$label:green': true } }),
|
|
];
|
|
expect(getThreadTagIds(emails).sort()).toEqual(['blue', 'green', 'red']);
|
|
});
|
|
|
|
it('reports a tag shared by several messages once', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { '$label:red': true } }),
|
|
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
|
];
|
|
expect(getThreadTagIds(emails)).toEqual(['red']);
|
|
});
|
|
|
|
it('reads the legacy prefix alongside the current one', () => {
|
|
const emails = [
|
|
makeEmail({ id: 'e1', keywords: { '$color:green': true } }),
|
|
makeEmail({ id: 'e2', keywords: { '$label:red': true } }),
|
|
];
|
|
expect(getThreadTagIds(emails).sort()).toEqual(['green', 'red']);
|
|
});
|
|
|
|
it('is empty for an untagged or empty thread', () => {
|
|
expect(getThreadTagIds([makeEmail({ id: 'e1', keywords: { $seen: true } })])).toEqual([]);
|
|
expect(getThreadTagIds([])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('cross-account thread identity (#1012)', () => {
|
|
// JMAP thread ids are per-account: Stalwart hands out counters like "b", so
|
|
// two accounts routinely have a thread "b" that has nothing in common.
|
|
const inAccount = (login: string, overrides: Partial<Email> = {}): Email =>
|
|
makeEmail({ sourceClientAccountId: `login-${login}`, sourceAccountId: `acct-${login}`, ...overrides });
|
|
|
|
it('keeps the bare thread id as key for unstamped emails', () => {
|
|
expect(threadKeyFor(makeEmail({ threadId: 'b' }))).toBe('b');
|
|
});
|
|
|
|
it('scopes the key by the source stamps', () => {
|
|
expect(threadKeyFor(inAccount('a', { threadId: 'b' }))).toBe('login-a/acct-a:b');
|
|
});
|
|
|
|
it('recovers the JMAP id from a key', () => {
|
|
expect(threadIdFromKey('login-a/acct-a:b')).toBe('b');
|
|
expect(threadIdFromKey('b')).toBe('b');
|
|
});
|
|
|
|
it('does not merge same-id threads that belong to different accounts', () => {
|
|
const groups = groupEmailsByThread([
|
|
inAccount('a', { id: 'a1', threadId: 'b' }),
|
|
inAccount('b', { id: 'b1', threadId: 'b' }),
|
|
]);
|
|
expect(groups).toHaveLength(2);
|
|
// The raw id is preserved for Thread/get on each account …
|
|
expect(groups.map((g) => g.threadId)).toEqual(['b', 'b']);
|
|
// … while the client-side identity tells them apart.
|
|
expect(new Set(groups.map((g) => g.threadKey)).size).toBe(2);
|
|
expect(groups.map((g) => g.emails.map((e) => e.id))).toEqual([['a1'], ['b1']]);
|
|
});
|
|
|
|
it('still merges the same thread within one account', () => {
|
|
const groups = groupEmailsByThread([
|
|
inAccount('a', { id: 'a1', threadId: 'b' }),
|
|
inAccount('a', { id: 'a2', threadId: 'b' }),
|
|
]);
|
|
expect(groups).toHaveLength(1);
|
|
expect(groups[0].emailCount).toBe(2);
|
|
});
|
|
|
|
it('looks thread counts up by key', () => {
|
|
const counts = new Map([['login-a/acct-a:b', 7]]);
|
|
const [group] = groupEmailsByThread([inAccount('a', { threadId: 'b' })], false, counts);
|
|
expect(group.emailCount).toBe(7);
|
|
});
|
|
|
|
it('uses the message id as both id and key when threading is disabled', () => {
|
|
const [group] = groupEmailsByThread([inAccount('a', { id: 'm1', threadId: 'b' })], true);
|
|
expect(group.threadKey).toBe('m1');
|
|
expect(group.threadId).toBe('m1');
|
|
});
|
|
});
|