mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
567 lines
22 KiB
TypeScript
567 lines
22 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 a thread read since it was opened where it was', () => {
|
|
const opened = makeGroup('opened', '2024-02-01T00:00:00Z');
|
|
opened.emails = [makeEmail({ id: 'o', receivedAt: '2024-02-01T00:00:00Z', keywords: { $seen: true } })];
|
|
const unread = makeGroup('unread', '2024-01-01T00:00:00Z');
|
|
unread.emails = [makeEmail({ id: 'u', receivedAt: '2024-01-01T00:00:00Z', keywords: {} })];
|
|
const readNewer = makeGroup('read-newer', '2024-06-01T00:00:00Z');
|
|
const order = [{ criterion: 'unread' as const, direction: 'desc' as const }];
|
|
|
|
expect(sortThreadGroups([readNewer, unread, opened], order).map(g => g.threadId))
|
|
.toEqual(['unread', 'read-newer', 'opened']);
|
|
expect(sortThreadGroups([readNewer, unread, opened], order, new Map([['o', {}]])).map(g => g.threadId))
|
|
.toEqual(['opened', 'unread', '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 as the thread when threading is disabled', () => {
|
|
const [group] = groupEmailsByThread([inAccount('a', { id: 'm1', threadId: 'b' })], true);
|
|
expect(group.threadKey).toBe('login-a/acct-a:m1');
|
|
expect(group.threadId).toBe('m1');
|
|
});
|
|
|
|
// The Scheduled list never threads. Live, the own and the group's scheduled
|
|
// mail were both "qaaaaae": one row, and the group's could not be opened.
|
|
it('keeps same-id messages of different accounts apart when threading is disabled', () => {
|
|
const groups = groupEmailsByThread([
|
|
inAccount('a', { id: 'm1', threadId: 'b' }),
|
|
inAccount('b', { id: 'm1', threadId: 'b' }),
|
|
], true);
|
|
expect(groups.map((g) => g.emails.length)).toEqual([1, 1]);
|
|
});
|
|
});
|