Files
SRCmail/lib/__tests__/thread-utils.test.ts
Mathy Vanvoorden d9d9f91a86 feat: Make it easier to handle multiple tags
- Tags can now be removed straight from the email header
- Tagging control now allows the user to (de)select multiple tags in one go
2026-07-29 17:18:42 +02:00

376 lines
13 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
groupEmailsByThread,
sortThreadGroups,
getThreadParticipants,
mergeThreadEmails,
getEmailTagId,
getEmailTagIds,
getThreadTagId,
getThreadTagIds,
} 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,
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('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);
});
});
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',
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',
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([]);
});
});
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([]);
});
});