feat: add contacts phase 2, advanced search, vacation responder, Docker & TOTP 2FA

- Contact groups/lists, vCard import/export (RFC 6350), bulk operations
- Advanced search with JMAP filter panel, search chips, cross-mailbox queries
- Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator
- TOTP two-factor authentication support
- Docker multi-stage build with standalone output and docker-compose
- CSP Report-Only headers and security headers via proxy middleware
- Virtual scrolling for large email lists
- Structured server-side logger (text/JSON, configurable level)
- 450+ tests (contacts, vCard, threads, headers, identity, components)
- Playwright E2E framework setup
- Updated README and ROADMAP with all new features
This commit is contained in:
Matthieu MALVACHE
2026-02-16 18:51:30 +01:00
committed by Matthieu MALVACHE
parent 8a5bc9b88d
commit fb414bf2c9
70 changed files with 8160 additions and 547 deletions
+245
View File
@@ -0,0 +1,245 @@
import { describe, it, expect } from 'vitest';
import {
parseAuthenticationResults,
parseSpamScore,
parseReceivedHeaders,
formatBytes,
getSecurityStatus,
parseSpamLLM,
extractListHeaders,
} from '../email-headers';
describe('parseAuthenticationResults', () => {
it('parses SPF pass with domain', () => {
const result = parseAuthenticationResults('spf=pass smtp.mailfrom=example.com');
expect(result.spf).toEqual({ result: 'pass', domain: 'example.com' });
});
it('parses DKIM pass with domain and selector', () => {
const result = parseAuthenticationResults('dkim=pass header.d=example.com header.s=selector1');
expect(result.dkim).toEqual({ result: 'pass', domain: 'example.com', selector: 'selector1' });
});
it('parses DMARC pass with domain', () => {
const result = parseAuthenticationResults('dmarc=pass header.from=example.com');
expect(result.dmarc).toEqual({ result: 'pass', domain: 'example.com', policy: undefined });
});
it('parses all three together separated by semicolons', () => {
const header = 'spf=pass smtp.mailfrom=example.com; dkim=pass header.d=example.com; dmarc=pass header.from=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('pass');
expect(result.dkim?.result).toBe('pass');
expect(result.dmarc?.result).toBe('pass');
});
it('parses failure results', () => {
expect(parseAuthenticationResults('spf=fail smtp.mailfrom=bad.com').spf?.result).toBe('fail');
expect(parseAuthenticationResults('dkim=fail header.d=bad.com').dkim?.result).toBe('fail');
expect(parseAuthenticationResults('dmarc=fail header.from=bad.com').dmarc?.result).toBe('fail');
});
it('returns empty object for unrecognized header', () => {
expect(parseAuthenticationResults('garbage header value')).toEqual({});
});
it('parses iprev with IP address', () => {
const result = parseAuthenticationResults('iprev=pass policy.iprev=192.168.1.1');
expect(result.iprev).toEqual({ result: 'pass', ip: '192.168.1.1' });
});
it('parses SPF softfail', () => {
const result = parseAuthenticationResults('spf=softfail smtp.mailfrom=example.com');
expect(result.spf?.result).toBe('softfail');
});
});
describe('parseSpamScore', () => {
it('parses X-Spam-Status "No" format', () => {
expect(parseSpamScore('No, score=-0.25')).toEqual({ status: 'no', score: -0.25 });
});
it('parses X-Spam-Status "Yes" format', () => {
expect(parseSpamScore('Yes, score=8.5')).toEqual({ status: 'yes', score: 8.5 });
});
it('extracts plain score and classifies as ham', () => {
expect(parseSpamScore('score=3.2')).toEqual({ score: 3.2, status: 'ham' });
});
it('extracts plain score and classifies as spam when above threshold', () => {
expect(parseSpamScore('score=6.0')).toEqual({ score: 6.0, status: 'spam' });
});
it('returns null for unrecognized format', () => {
expect(parseSpamScore('nothing useful here')).toBeNull();
});
});
describe('parseReceivedHeaders', () => {
it('parses a single received header', () => {
const headers = ['from mail.example.com by mx.example.com with SMTP id abc123; Mon, 15 Jan 2024 10:00:00 +0000'];
const result = parseReceivedHeaders(headers);
expect(result).toHaveLength(1);
expect(result[0].from).toBe('mail.example.com');
expect(result[0].by).toBe('mx.example.com');
expect(result[0].protocol).toBe('SMTP');
expect(result[0].id).toBe('abc123');
expect(result[0].timestamp).toBe('Mon, 15 Jan 2024 10:00:00 +0000');
});
it('handles missing fields gracefully', () => {
const result = parseReceivedHeaders(['from sender.example.com']);
expect(result).toHaveLength(1);
expect(result[0].from).toBe('sender.example.com');
expect(result[0].by).toBe('unknown');
});
it('returns empty array for empty input', () => {
expect(parseReceivedHeaders([])).toEqual([]);
});
it('skips headers with no from or by', () => {
expect(parseReceivedHeaders(['random text without routing info'])).toEqual([]);
});
});
describe('formatBytes', () => {
it('formats 0 bytes', () => {
expect(formatBytes(0)).toBe('0 B');
});
it('formats bytes', () => {
expect(formatBytes(512)).toBe('512.0 B');
});
it('formats kilobytes', () => {
expect(formatBytes(1024)).toBe('1.0 KB');
});
it('formats megabytes', () => {
expect(formatBytes(1048576)).toBe('1.0 MB');
});
it('formats gigabytes', () => {
expect(formatBytes(1073741824)).toBe('1.0 GB');
});
});
describe('getSecurityStatus', () => {
it('returns green for pass', () => {
const status = getSecurityStatus('pass');
expect(status.icon).toBe('check');
expect(status.color).toContain('green');
expect(status.borderColor).toContain('green');
});
it('returns red for fail', () => {
const status = getSecurityStatus('fail');
expect(status.icon).toBe('x');
expect(status.color).toContain('red');
});
it('returns red for permerror', () => {
const status = getSecurityStatus('permerror');
expect(status.icon).toBe('x');
expect(status.color).toContain('red');
});
it('returns amber for softfail', () => {
const status = getSecurityStatus('softfail');
expect(status.icon).toBe('alert');
expect(status.color).toContain('amber');
});
it('returns amber for neutral and temperror', () => {
expect(getSecurityStatus('neutral').icon).toBe('alert');
expect(getSecurityStatus('temperror').icon).toBe('alert');
});
it('returns gray for undefined', () => {
const status = getSecurityStatus(undefined);
expect(status.icon).toBe('minus');
expect(status.color).toContain('gray');
});
});
describe('parseSpamLLM', () => {
it('parses LEGITIMATE verdict', () => {
expect(parseSpamLLM('LEGITIMATE (This is a normal email)')).toEqual({
verdict: 'LEGITIMATE',
explanation: 'This is a normal email',
});
});
it('parses SPAM verdict', () => {
expect(parseSpamLLM('SPAM (Unsolicited bulk message)')).toEqual({
verdict: 'SPAM',
explanation: 'Unsolicited bulk message',
});
});
it('parses SUSPICIOUS verdict', () => {
expect(parseSpamLLM('SUSPICIOUS (Possible phishing attempt)')).toEqual({
verdict: 'SUSPICIOUS',
explanation: 'Possible phishing attempt',
});
});
it('is case-insensitive for verdict keyword', () => {
expect(parseSpamLLM('legitimate (test)')).toEqual({
verdict: 'LEGITIMATE',
explanation: 'test',
});
});
it('returns null for unrecognized format', () => {
expect(parseSpamLLM('some random header')).toBeNull();
expect(parseSpamLLM('')).toBeNull();
});
});
describe('extractListHeaders', () => {
it('extracts List-Id', () => {
const result = extractListHeaders({ 'List-Id': 'My Newsletter <list.example.com>' });
expect(result.listId).toBe('My Newsletter <list.example.com>');
});
it('extracts List-Unsubscribe with HTTP URL', () => {
const result = extractListHeaders({
'List-Unsubscribe': '<https://example.com/unsubscribe?id=123>',
});
expect(result.listUnsubscribe?.http).toBe('https://example.com/unsubscribe?id=123');
expect(result.listUnsubscribe?.preferred).toBe('http');
});
it('extracts List-Unsubscribe with both HTTP and mailto', () => {
const result = extractListHeaders({
'List-Unsubscribe': '<https://example.com/unsub>, <mailto:unsub@example.com>',
});
expect(result.listUnsubscribe?.http).toBe('https://example.com/unsub');
expect(result.listUnsubscribe?.mailto).toBe('mailto:unsub@example.com');
expect(result.listUnsubscribe?.preferred).toBe('http');
});
it('handles array header values', () => {
const result = extractListHeaders({
'List-Id': ['Newsletter <list.example.com>', 'fallback'],
});
expect(result.listId).toBe('Newsletter <list.example.com>');
});
it('returns empty object when no list headers present', () => {
expect(extractListHeaders({})).toEqual({});
expect(extractListHeaders({ 'Subject': 'hello' })).toEqual({});
});
it('extracts List-Help and List-Post', () => {
const result = extractListHeaders({
'List-Help': '<mailto:help@example.com>',
'List-Post': '<mailto:post@example.com>',
});
expect(result.listHelp).toBe('<mailto:help@example.com>');
expect(result.listPost).toBe('<mailto:post@example.com>');
});
});
+561
View File
@@ -0,0 +1,561 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
const mockContact = {
id: 'contact-1',
addressBookIds: { 'ab-1': true },
name: { components: [{ kind: 'given' as const, value: 'John' }, { kind: 'surname' as const, value: 'Doe' }], isOrdered: true },
emails: { e0: { address: 'john@example.com' } },
};
const mockAddressBook = {
id: 'ab-1',
name: 'Default',
isDefault: true,
};
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
});
return client;
}
function mockFetch(response: object, ok = true, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}
function mockFetchOnce(spy: ReturnType<typeof vi.spyOn>, response: object) {
spy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
return spy;
}
describe('JMAPClient contact methods', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('supportsContacts', () => {
it('should return true when contacts capability exists', () => {
const client = createClient();
Object.assign(client, { capabilities: { 'urn:ietf:params:jmap:contacts': {} } });
expect(client.supportsContacts()).toBe(true);
});
it('should return false when contacts capability is missing', () => {
const client = createClient();
Object.assign(client, { capabilities: {} });
expect(client.supportsContacts()).toBe(false);
});
it('should throw when capabilities is undefined', () => {
const client = createClient();
Object.assign(client, { capabilities: undefined });
expect(() => client.supportsContacts()).toThrow();
});
});
describe('getAddressBooks', () => {
it('should return address books from server', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
const result = await client.getAddressBooks();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('ab-1');
expect(result[0].name).toBe('Default');
});
it('should return empty array when no address books', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', { list: [] }, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
it('should return empty array when list is missing', async () => {
const client = createClient();
mockFetch({
methodResponses: [['AddressBook/get', {}, '0']],
});
const result = await client.getAddressBooks();
expect(result).toEqual([]);
});
});
describe('getContacts', () => {
it('should return contacts from server', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
const result = await client.getContacts();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('contact-1');
});
it('should filter by addressBookId when provided', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
await client.getContacts('ab-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toEqual({ inAddressBook: 'ab-1' });
});
it('should not include filter when no addressBookId', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
await client.getContacts();
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toBeUndefined();
});
it('should return empty array when no contacts', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
const result = await client.getContacts();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getContacts();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response at index 1', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['SomethingElse', {}, '1'],
],
});
const result = await client.getContacts();
expect(result).toEqual([]);
});
});
describe('getContact', () => {
it('should return a single contact', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
});
const result = await client.getContact('contact-1');
expect(result).not.toBeNull();
expect(result!.id).toBe('contact-1');
});
it('should pass contact id in the request', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/get', { list: [mockContact] }, '0']],
});
await client.getContact('contact-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].ids).toEqual(['contact-1']);
});
it('should return null when contact not found', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/get', { list: [] }, '0']],
});
const result = await client.getContact('nonexistent');
expect(result).toBeNull();
});
it('should return null on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getContact('contact-1');
expect(result).toBeNull();
});
it('should return null for unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getContact('contact-1');
expect(result).toBeNull();
});
});
describe('createContact', () => {
it('should create contact and refetch full object', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// 1: getAddressBooks
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
// 2: ContactCard/set
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
// 3: getContact refetch
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
const result = await client.createContact({ name: mockContact.name, emails: mockContact.emails });
expect(result.id).toBe('new-id');
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it('should skip getAddressBooks when addressBookIds provided', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// 1: ContactCard/set (no getAddressBooks needed)
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
// 2: getContact refetch
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [{ ...mockContact, id: 'new-id' }] }, '0']],
});
const result = await client.createContact({
name: mockContact.name,
addressBookIds: { 'ab-1': true },
});
expect(result.id).toBe('new-id');
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('should throw on notCreated error with description', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'Missing required fields' } },
}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Missing required fields');
});
it('should throw generic error when notCreated has no description', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', {
notCreated: { 'new-contact': { type: 'forbidden' } },
}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw when created id is missing', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: {} }, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
it('should throw when refetch returns null', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
mockFetchOnce(fetchSpy, {
methodResponses: [['AddressBook/get', { list: [mockAddressBook] }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'new-id' } } }, '0']],
});
mockFetchOnce(fetchSpy, {
methodResponses: [['ContactCard/get', { list: [] }, '0']],
});
await expect(client.createContact({ name: mockContact.name }))
.rejects.toThrow('Failed to create contact');
});
});
describe('updateContact', () => {
it('should update contact successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name })).resolves.toBeUndefined();
});
it('should pass updates in the request body', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/set', { updated: { 'contact-1': null } }, '0']],
});
const updates = { name: { components: [{ kind: 'given' as const, value: 'Jane' }], isOrdered: true } };
await client.updateContact('contact-1', updates);
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].update['contact-1']).toEqual(updates);
});
it('should throw on notUpdated error with description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notUpdated: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Contact not found');
});
it('should throw generic error when notUpdated has no description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notUpdated: { 'contact-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Failed to update contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.updateContact('contact-1', { name: mockContact.name }))
.rejects.toThrow('Failed to update contact');
});
});
describe('deleteContact', () => {
it('should delete contact successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
});
await expect(client.deleteContact('contact-1')).resolves.toBeUndefined();
});
it('should pass contact id in destroy array', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [['ContactCard/set', { destroyed: ['contact-1'] }, '0']],
});
await client.deleteContact('contact-1');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].destroy).toEqual(['contact-1']);
});
it('should throw on notDestroyed error with description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notDestroyed: { 'contact-1': { type: 'notFound', description: 'Contact not found' } },
}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Contact not found');
});
it('should throw generic error when notDestroyed has no description', async () => {
const client = createClient();
mockFetch({
methodResponses: [['ContactCard/set', {
notDestroyed: { 'contact-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Failed to delete contact');
});
it('should throw on unexpected response method', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.deleteContact('contact-1'))
.rejects.toThrow('Failed to delete contact');
});
});
describe('searchContacts', () => {
it('should return matching contacts', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: ['contact-1'] }, '0'],
['ContactCard/get', { list: [mockContact] }, '1'],
],
});
const result = await client.searchContacts('John');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('contact-1');
});
it('should pass query as text filter', async () => {
const client = createClient();
const fetchSpy = mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
await client.searchContacts('Jane');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
expect(body.methodCalls[0][1].filter).toEqual({ text: 'Jane' });
});
it('should return empty array when no results', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['ContactCard/get', { list: [] }, '1'],
],
});
const result = await client.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.searchContacts('John');
expect(result).toEqual([]);
});
it('should return empty array for unexpected response at index 1', async () => {
const client = createClient();
mockFetch({
methodResponses: [
['ContactCard/query', { ids: [] }, '0'],
['SomethingElse', {}, '1'],
],
});
const result = await client.searchContacts('John');
expect(result).toEqual([]);
});
});
});
+309
View File
@@ -0,0 +1,309 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
const mockIdentity = {
id: 'id-1',
name: 'Test User',
email: 'test@example.com',
mayDelete: true,
};
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user', 'pass');
// Set internal state so request() doesn't throw "Not connected"
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
});
return client;
}
function mockFetch(response: object, ok = true, status = 200) {
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok,
status,
text: () => Promise.resolve(JSON.stringify(response)),
json: () => Promise.resolve(response),
} as Response);
}
describe('JMAPClient identity methods', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('getIdentities', () => {
it('should return identities from server', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/get', { list: [mockIdentity] }, '0']],
});
const result = await client.getIdentities();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('id-1');
expect(result[0].email).toBe('test@example.com');
});
it('should return empty array when no identities', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/get', { list: [] }, '0']],
});
const result = await client.getIdentities();
expect(result).toEqual([]);
});
it('should return empty array on network error', async () => {
const client = createClient();
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
const result = await client.getIdentities();
expect(result).toEqual([]);
});
it('should return empty array for unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
const result = await client.getIdentities();
expect(result).toEqual([]);
});
it('should handle missing list property', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/get', {}, '0']],
});
const result = await client.getIdentities();
expect(result).toEqual([]);
});
});
describe('createIdentity', () => {
it('should create identity and return full object', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
// First call: Identity/set returns created id
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
}),
} as Response);
// Second call: getIdentities fetches full object
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
}),
} as Response);
const result = await client.createIdentity('Test User', 'test@example.com');
expect(result.id).toBe('new-id');
});
it('should throw on forbidden error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notCreated: { 'new-identity': { type: 'forbidden' } },
}, '0']],
});
await expect(client.createIdentity('Test', 'test@example.com'))
.rejects.toThrow('not authorized');
});
it('should throw on generic creation error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notCreated: { 'new-identity': { type: 'invalidProperties', description: 'Bad input' } },
}, '0']],
});
await expect(client.createIdentity('Test', 'test@example.com'))
.rejects.toThrow('Bad input');
});
it('should throw on unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.createIdentity('Test', 'test@example.com'))
.rejects.toThrow('unexpected');
});
it('should pass all parameters to the request', async () => {
const client = createClient();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/set', { created: { 'new-identity': { id: 'new-id' } } }, '0']],
}),
} as Response);
fetchSpy.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
})),
json: () => Promise.resolve({
methodResponses: [['Identity/get', { list: [{ ...mockIdentity, id: 'new-id' }] }, '0']],
}),
} as Response);
const replyTo = [{ name: 'Reply', email: 'reply@example.com' }];
const bcc = [{ email: 'bcc@example.com' }];
await client.createIdentity('Test', 'test@example.com', replyTo, bcc, 'text sig', '<b>html sig</b>');
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
const createArgs = body.methodCalls[0][1].create['new-identity'];
expect(createArgs.name).toBe('Test');
expect(createArgs.email).toBe('test@example.com');
expect(createArgs.replyTo).toEqual(replyTo);
expect(createArgs.bcc).toEqual(bcc);
expect(createArgs.textSignature).toBe('text sig');
expect(createArgs.htmlSignature).toBe('<b>html sig</b>');
});
});
describe('updateIdentity', () => {
it('should update identity successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', { updated: { 'id-1': null } }, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'New Name' })).resolves.toBeUndefined();
});
it('should throw on notFound error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notUpdated: { 'id-1': { type: 'notFound' } },
}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('not found');
});
it('should throw on forbidden error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notUpdated: { 'id-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('not authorized');
});
it('should throw on generic update error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notUpdated: { 'id-1': { type: 'other', description: 'Server error' } },
}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('Server error');
});
it('should throw on unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.updateIdentity('id-1', { name: 'X' }))
.rejects.toThrow('unexpected');
});
});
describe('deleteIdentity', () => {
it('should delete identity successfully', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', { destroyed: ['id-1'] }, '0']],
});
await expect(client.deleteIdentity('id-1')).resolves.toBeUndefined();
});
it('should throw on forbidden error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notDestroyed: { 'id-1': { type: 'forbidden' } },
}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('cannot be deleted');
});
it('should throw on notFound error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notDestroyed: { 'id-1': { type: 'notFound' } },
}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('not found');
});
it('should throw on generic delete error', async () => {
const client = createClient();
mockFetch({
methodResponses: [['Identity/set', {
notDestroyed: { 'id-1': { type: 'other', description: 'Cannot remove' } },
}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('Cannot remove');
});
it('should throw on unexpected response', async () => {
const client = createClient();
mockFetch({
methodResponses: [['SomethingElse', {}, '0']],
});
await expect(client.deleteIdentity('id-1'))
.rejects.toThrow('unexpected');
});
});
});
+356
View File
@@ -0,0 +1,356 @@
import { describe, it, expect } from 'vitest';
import {
parseSubAddress,
generateSubAddress,
extractDomain,
suggestTagsForDomain,
isValidTag,
getTagValidationError,
MAX_TAG_LENGTH,
} from '../sub-addressing';
describe('parseSubAddress', () => {
describe('standard addresses', () => {
it('should parse email with tag', () => {
const result = parseSubAddress('user+shopping@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('shopping');
expect(result.domain).toBe('example.com');
expect(result.localPart).toBe('user+shopping');
expect(result.fullAddress).toBe('user+shopping@example.com');
});
it('should parse email with alphanumeric tag', () => {
const result = parseSubAddress('john+news2024@domain.co.uk');
expect(result.baseUser).toBe('john');
expect(result.tag).toBe('news2024');
expect(result.domain).toBe('domain.co.uk');
});
it('should parse email with dash in tag', () => {
const result = parseSubAddress('alice+my-orders@shop.com');
expect(result.tag).toBe('my-orders');
});
});
describe('no tag', () => {
it('should handle email without plus sign', () => {
const result = parseSubAddress('user@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBeNull();
expect(result.domain).toBe('example.com');
expect(result.localPart).toBe('user');
});
it('should handle dotted local part without tag', () => {
const result = parseSubAddress('first.last@example.com');
expect(result.baseUser).toBe('first.last');
expect(result.tag).toBeNull();
});
});
describe('multiple plus signs', () => {
it('should use first plus as separator', () => {
const result = parseSubAddress('user+tag1+tag2@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBe('tag1+tag2');
expect(result.localPart).toBe('user+tag1+tag2');
});
});
describe('empty tag', () => {
it('should return null tag for trailing plus', () => {
const result = parseSubAddress('user+@example.com');
expect(result.baseUser).toBe('user');
expect(result.tag).toBeNull();
});
});
describe('edge cases', () => {
it('should handle missing domain', () => {
const result = parseSubAddress('user');
expect(result.localPart).toBe('user');
expect(result.baseUser).toBe('user');
expect(result.tag).toBeNull();
expect(result.domain).toBe('');
});
it('should handle missing local part', () => {
const result = parseSubAddress('@example.com');
expect(result.localPart).toBe('');
expect(result.baseUser).toBe('');
expect(result.tag).toBeNull();
expect(result.domain).toBe('example.com');
});
it('should handle empty string', () => {
const result = parseSubAddress('');
expect(result.localPart).toBe('');
expect(result.baseUser).toBe('');
expect(result.tag).toBeNull();
expect(result.domain).toBe('');
});
it('should preserve full address', () => {
const email = 'test+dev@mail.example.org';
const result = parseSubAddress(email);
expect(result.fullAddress).toBe(email);
});
it('should handle plus at start of local part', () => {
const result = parseSubAddress('+tag@example.com');
expect(result.baseUser).toBe('');
expect(result.tag).toBe('tag');
});
});
});
describe('generateSubAddress', () => {
describe('basic generation', () => {
it('should generate tagged address', () => {
expect(generateSubAddress('user@example.com', 'shopping')).toBe('user+shopping@example.com');
});
it('should lowercase the tag', () => {
expect(generateSubAddress('user@example.com', 'Shopping')).toBe('user+shopping@example.com');
});
it('should allow dashes in tag', () => {
expect(generateSubAddress('user@example.com', 'my-orders')).toBe('user+my-orders@example.com');
});
});
describe('replace existing tag', () => {
it('should replace existing tag with new one', () => {
expect(generateSubAddress('user+old@example.com', 'new')).toBe('user+new@example.com');
});
it('should replace complex existing tag', () => {
expect(generateSubAddress('user+tag1+tag2@example.com', 'fresh')).toBe('user+fresh@example.com');
});
});
describe('empty or invalid tag', () => {
it('should return original email for empty tag', () => {
expect(generateSubAddress('user@example.com', '')).toBe('user@example.com');
});
it('should return original email for tag with only invalid chars', () => {
expect(generateSubAddress('user@example.com', '!@#$%')).toBe('user@example.com');
});
});
describe('tag sanitization', () => {
it('should strip special characters from tag', () => {
expect(generateSubAddress('user@example.com', 'my_tag!')).toBe('user+mytag@example.com');
});
it('should strip spaces from tag', () => {
expect(generateSubAddress('user@example.com', 'my tag')).toBe('user+mytag@example.com');
});
it('should keep alphanumeric and dash', () => {
expect(generateSubAddress('user@example.com', 'valid-tag-123')).toBe('user+valid-tag-123@example.com');
});
});
describe('missing domain', () => {
it('should return original for email without domain', () => {
expect(generateSubAddress('user', 'tag')).toBe('user');
});
it('should return original for email without local part', () => {
expect(generateSubAddress('@example.com', 'tag')).toBe('@example.com');
});
});
});
describe('extractDomain', () => {
it('should extract domain from standard email', () => {
expect(extractDomain('user@example.com')).toBe('example.com');
});
it('should extract domain from sub-addressed email', () => {
expect(extractDomain('user+tag@mail.example.org')).toBe('mail.example.org');
});
it('should normalize domain to lowercase', () => {
expect(extractDomain('user@EXAMPLE.COM')).toBe('example.com');
});
it('should return null for email without @', () => {
expect(extractDomain('nodomain')).toBeNull();
});
it('should return null for empty string', () => {
expect(extractDomain('')).toBeNull();
});
it('should handle multiple @ symbols', () => {
expect(extractDomain('user@host@example.com')).toBe('example.com');
});
});
describe('suggestTagsForDomain', () => {
describe('known domains', () => {
it('should return suggestions for amazon.com', () => {
const tags = suggestTagsForDomain('amazon.com');
expect(tags).toContain('amazon');
expect(tags).toContain('shopping');
expect(tags).toContain('orders');
});
it('should return suggestions for github.com', () => {
const tags = suggestTagsForDomain('github.com');
expect(tags).toContain('github');
expect(tags).toContain('dev');
expect(tags).toContain('notifications');
});
it('should return suggestions for paypal.com', () => {
const tags = suggestTagsForDomain('paypal.com');
expect(tags).toContain('paypal');
expect(tags).toContain('payments');
});
it('should return suggestions for netflix.com', () => {
const tags = suggestTagsForDomain('netflix.com');
expect(tags).toContain('netflix');
expect(tags).toContain('entertainment');
});
it('should return suggestions for regional Amazon domains', () => {
expect(suggestTagsForDomain('amazon.fr')).toContain('amazon');
expect(suggestTagsForDomain('amazon.de')).toContain('shopping');
expect(suggestTagsForDomain('amazon.co.uk')).toContain('orders');
});
});
describe('unknown domains', () => {
it('should return generic suggestions with domain name', () => {
const tags = suggestTagsForDomain('randomsite.com');
expect(tags).toContain('randomsite');
expect(tags).toContain('newsletter');
expect(tags).toContain('registration');
});
it('should extract main domain from multi-part TLD', () => {
const tags = suggestTagsForDomain('unknown.co.uk');
expect(tags[0]).toBe('co');
});
});
describe('subdomains', () => {
it('should extract main domain from subdomain', () => {
const tags = suggestTagsForDomain('mail.google.com');
expect(tags[0]).toBe('google');
});
it('should extract main domain from deep subdomain', () => {
const tags = suggestTagsForDomain('smtp.mail.provider.com');
expect(tags[0]).toBe('provider');
});
});
describe('case-insensitive matching', () => {
it('should match known domains case-insensitively', () => {
expect(suggestTagsForDomain('GITHUB.COM')).toContain('github');
expect(suggestTagsForDomain('GitHub.com')).toContain('github');
});
it('should match regional domains case-insensitively', () => {
expect(suggestTagsForDomain('AMAZON.FR')).toContain('amazon');
});
});
});
describe('isValidTag', () => {
describe('valid tags', () => {
it('should accept lowercase letters', () => {
expect(isValidTag('shopping')).toBe(true);
});
it('should accept uppercase letters', () => {
expect(isValidTag('Shopping')).toBe(true);
});
it('should accept numbers', () => {
expect(isValidTag('tag123')).toBe(true);
});
it('should accept dashes', () => {
expect(isValidTag('my-tag')).toBe(true);
});
it('should accept single character', () => {
expect(isValidTag('a')).toBe(true);
});
it('should accept max length tag', () => {
expect(isValidTag('a'.repeat(MAX_TAG_LENGTH))).toBe(true);
});
});
describe('invalid tags', () => {
it('should reject empty string', () => {
expect(isValidTag('')).toBe(false);
});
it('should reject underscores', () => {
expect(isValidTag('my_tag')).toBe(false);
});
it('should reject dots', () => {
expect(isValidTag('my.tag')).toBe(false);
});
it('should reject spaces', () => {
expect(isValidTag('my tag')).toBe(false);
});
it('should reject special characters', () => {
expect(isValidTag('tag!')).toBe(false);
expect(isValidTag('tag@')).toBe(false);
expect(isValidTag('tag#')).toBe(false);
});
it('should reject tag exceeding max length', () => {
expect(isValidTag('a'.repeat(MAX_TAG_LENGTH + 1))).toBe(false);
});
});
});
describe('getTagValidationError', () => {
it('should return null for valid tag', () => {
expect(getTagValidationError('shopping')).toBeNull();
expect(getTagValidationError('my-tag-123')).toBeNull();
});
it('should return EMPTY for empty string', () => {
expect(getTagValidationError('')).toBe('EMPTY');
});
it('should return TOO_LONG for oversized tag', () => {
expect(getTagValidationError('a'.repeat(MAX_TAG_LENGTH + 1))).toBe('TOO_LONG');
});
it('should return INVALID_CHARS for special characters', () => {
expect(getTagValidationError('tag!')).toBe('INVALID_CHARS');
expect(getTagValidationError('tag with spaces')).toBe('INVALID_CHARS');
expect(getTagValidationError('tag_underscore')).toBe('INVALID_CHARS');
});
it('should check length before characters', () => {
const longInvalid = '!'.repeat(MAX_TAG_LENGTH + 1);
expect(getTagValidationError(longInvalid)).toBe('TOO_LONG');
});
it('should return null for boundary-length valid tag', () => {
expect(getTagValidationError('a'.repeat(MAX_TAG_LENGTH))).toBeNull();
});
it('should return INVALID_CHARS for unicode characters', () => {
expect(getTagValidationError('café')).toBe('INVALID_CHARS');
expect(getTagValidationError('日本語')).toBe('INVALID_CHARS');
});
});
+242
View File
@@ -0,0 +1,242 @@
import { describe, it, expect } from 'vitest';
import {
groupEmailsByThread,
sortThreadGroups,
getThreadParticipants,
mergeThreadEmails,
getEmailColorTag,
getThreadColorTag,
} 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('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', () => {
it('sorts groups by latestEmail.receivedAt descending', () => {
const groups: ThreadGroup[] = [
{
threadId: 'old',
emails: [makeEmail({ receivedAt: '2024-01-01T00:00:00Z' })],
latestEmail: makeEmail({ receivedAt: '2024-01-01T00:00:00Z' }),
participantNames: ['A'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
emailCount: 1,
},
{
threadId: 'new',
emails: [makeEmail({ receivedAt: '2024-06-01T00:00:00Z' })],
latestEmail: makeEmail({ receivedAt: '2024-06-01T00:00:00Z' }),
participantNames: ['B'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
emailCount: 1,
},
];
const sorted = sortThreadGroups(groups);
expect(sorted[0].threadId).toBe('new');
expect(sorted[1].threadId).toBe('old');
});
});
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,
hasAttachment: 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,
hasAttachment: 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('getEmailColorTag', () => {
it('returns color from $color: keyword', () => {
expect(getEmailColorTag({ '$color:red': true, $seen: true })).toBe('red');
});
it('returns null when no color keyword', () => {
expect(getEmailColorTag({ $seen: true, $flagged: true })).toBeNull();
});
it('returns null for undefined keywords', () => {
expect(getEmailColorTag(undefined)).toBeNull();
});
});
describe('getThreadColorTag', () => {
it('returns first color found across thread emails', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { '$color:blue': true } }),
];
expect(getThreadColorTag(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(getThreadColorTag(emails)).toBeNull();
});
});
+393
View File
@@ -0,0 +1,393 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseVCard, generateVCard, detectDuplicates } from "../vcard";
import type { ContactCard } from "@/lib/jmap/types";
beforeEach(() => {
vi.stubGlobal("crypto", { randomUUID: () => "test-uuid" });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("parseVCard", () => {
it("parses single vCard with FN (full name)", () => {
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEMAIL:john@example.com\r\nEND:VCARD`;
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.id).toBe("import-test-uuid");
expect(card.name?.components).toEqual(
expect.arrayContaining([
{ kind: "given", value: "John" },
{ kind: "surname", value: "Doe" },
])
);
expect(card.emails?.e0?.address).toBe("john@example.com");
});
it("parses vCard with N field (structured name with all components)", () => {
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;Michael;Mr.;Jr.\r\nEMAIL:john@example.com\r\nEND:VCARD`;
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const components = result[0].name?.components || [];
expect(components).toEqual([
{ kind: "prefix", value: "Mr." },
{ kind: "given", value: "John" },
{ kind: "additional", value: "Michael" },
{ kind: "surname", value: "Doe" },
{ kind: "suffix", value: "Jr." },
]);
});
it("N field overrides FN when both present", () => {
const vcf = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nN:Doe;John;;;\r\nEMAIL:john@example.com\r\nEND:VCARD`;
const result = parseVCard(vcf);
// N comes after FN in raw lines, and N always sets card.name (overwrites FN)
const components = result[0].name?.components || [];
expect(components.find((c) => c.kind === "given")?.value).toBe("John");
expect(components.find((c) => c.kind === "surname")?.value).toBe("Doe");
});
it("parses vCard with phone, org, and address", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Jane Smith",
"EMAIL;TYPE=WORK:jane@work.com",
"TEL;TYPE=CELL:+1234567890",
"ORG:Acme Corp;Engineering",
"ADR;TYPE=WORK:;;123 Main St;City;State;12345;US",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.emails?.e0?.address).toBe("jane@work.com");
expect(card.emails?.e0?.contexts).toEqual({ work: true });
expect(card.phones?.p0?.number).toBe("+1234567890");
expect(card.organizations?.o0?.name).toBe("Acme Corp");
expect(card.organizations?.o0?.units).toEqual([{ name: "Engineering" }]);
expect(card.addresses?.a0).toMatchObject({
street: "123 Main St",
locality: "City",
region: "State",
postcode: "12345",
country: "US",
contexts: { work: true },
});
});
it("parses vCard with nickname, notes, and UID", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Bob Builder",
"NICKNAME:Bobby",
"NOTE:Important person",
"UID:abc-123",
"EMAIL:bob@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
const card = result[0];
expect(card.nicknames?.n0?.name).toBe("Bobby");
expect(card.notes?.n0?.note).toBe("Important person");
expect(card.uid).toBe("abc-123");
});
it("parses multi-contact vCard file", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Alice",
"EMAIL:alice@example.com",
"END:VCARD",
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Bob",
"EMAIL:bob@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(2);
expect(result[0].emails?.e0?.address).toBe("alice@example.com");
expect(result[1].emails?.e0?.address).toBe("bob@example.com");
});
it("skips malformed vCards without name or email", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"NOTE:Just a note",
"END:VCARD",
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Valid Contact",
"EMAIL:valid@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
expect(result[0].emails?.e0?.address).toBe("valid@example.com");
});
it("parses vCard with group kind and members", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Team Alpha",
"KIND:group",
"MEMBER:urn:uuid:member-1",
"MEMBER:urn:uuid:member-2",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
const card = result[0];
expect(card.kind).toBe("group");
expect(card.members).toEqual({ "member-1": true, "member-2": true });
});
it("handles folded lines (continuation with leading space)", () => {
const vcf =
"BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John\r\n Doe\r\nEMAIL:john@example.com\r\nEND:VCARD";
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
expect(result[0].name?.components).toEqual(
expect.arrayContaining([{ kind: "given", value: "JohnDoe" }])
);
});
it("handles escaped characters", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:Test User",
"NOTE:Line one\\nLine two\\, with comma\\; and semicolon\\\\backslash",
"EMAIL:test@example.com",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result[0].notes?.n0?.note).toBe(
"Line one\nLine two, with comma; and semicolon\\backslash"
);
});
it("allows group kind without name or email", () => {
const vcf = [
"BEGIN:VCARD",
"VERSION:3.0",
"KIND:group",
"MEMBER:urn:uuid:m1",
"END:VCARD",
].join("\r\n");
const result = parseVCard(vcf);
expect(result).toHaveLength(1);
expect(result[0].kind).toBe("group");
});
});
describe("generateVCard", () => {
it("exports single contact with all fields", () => {
const contact: ContactCard = {
id: "c1",
uid: "uid-1",
addressBookIds: { ab1: true },
kind: "individual",
name: {
components: [
{ kind: "prefix", value: "Dr." },
{ kind: "given", value: "Jane" },
{ kind: "additional", value: "Marie" },
{ kind: "surname", value: "Smith" },
{ kind: "suffix", value: "PhD" },
],
isOrdered: true,
},
emails: {
e0: { address: "jane@work.com", contexts: { work: true } },
e1: { address: "jane@home.com", contexts: { private: true } },
},
phones: { p0: { number: "+1234567890", contexts: { work: true } } },
organizations: {
o0: { name: "Acme Corp", units: [{ name: "Engineering" }] },
},
addresses: {
a0: {
street: "123 Main St",
locality: "City",
region: "State",
postcode: "12345",
country: "US",
contexts: { work: true },
},
},
nicknames: { n0: { name: "JJ" } },
notes: { n0: { note: "VIP client" } },
};
const vcf = generateVCard([contact]);
expect(vcf).toContain("BEGIN:VCARD");
expect(vcf).toContain("END:VCARD");
expect(vcf).toContain("VERSION:3.0");
expect(vcf).toContain("UID:uid-1");
expect(vcf).toContain("KIND:individual");
expect(vcf).toContain("FN:Jane Smith");
expect(vcf).toContain("N:Smith;Jane;Marie;Dr.;PhD");
expect(vcf).toContain("NICKNAME:JJ");
expect(vcf).toContain("EMAIL;TYPE=WORK:jane@work.com");
expect(vcf).toContain("EMAIL;TYPE=HOME:jane@home.com");
expect(vcf).toContain("TEL;TYPE=WORK:+1234567890");
expect(vcf).toContain("ORG:Acme Corp;Engineering");
expect(vcf).toContain("ADR;TYPE=WORK:;;123 Main St;City;State;12345;US");
expect(vcf).toContain("NOTE:VIP client");
});
it("produces valid structure for minimal contact", () => {
const contact: ContactCard = {
id: "c2",
addressBookIds: {},
name: {
components: [{ kind: "given", value: "Solo" }],
isOrdered: true,
},
};
const vcf = generateVCard([contact]);
const lines = vcf.split("\r\n");
expect(lines[0]).toBe("BEGIN:VCARD");
expect(lines[1]).toBe("VERSION:3.0");
expect(lines).toContain("FN:Solo");
expect(lines).toContain("N:;Solo;;;");
expect(lines[lines.length - 1]).toBe("END:VCARD");
});
it("encodes special characters in values", () => {
const contact: ContactCard = {
id: "c3",
addressBookIds: {},
name: {
components: [{ kind: "given", value: "Test" }],
isOrdered: true,
},
notes: { n0: { note: "Has comma, semicolon; and newline\nhere" } },
};
const vcf = generateVCard([contact]);
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
});
});
describe("round-trip: parse → generate → parse", () => {
it("produces structurally equivalent data", () => {
const original = [
"BEGIN:VCARD",
"VERSION:3.0",
"FN:John Doe",
"N:Doe;John;;;",
"EMAIL;TYPE=WORK:john@work.com",
"TEL:+1234567890",
"ORG:Acme Corp",
"NICKNAME:JD",
"NOTE:A note",
"UID:round-trip-1",
"END:VCARD",
].join("\r\n");
const parsed = parseVCard(original);
const exported = generateVCard(parsed);
const reparsed = parseVCard(exported);
expect(reparsed).toHaveLength(1);
const a = parsed[0];
const b = reparsed[0];
expect(b.name?.components).toEqual(a.name?.components);
expect(b.emails?.e0?.address).toBe(a.emails?.e0?.address);
expect(b.phones?.p0?.number).toBe(a.phones?.p0?.number);
expect(b.organizations?.o0?.name).toBe(a.organizations?.o0?.name);
expect(b.nicknames?.n0?.name).toBe(a.nicknames?.n0?.name);
expect(b.notes?.n0?.note).toBe(a.notes?.n0?.note);
expect(b.uid).toBe(a.uid);
});
});
describe("detectDuplicates", () => {
it("detects duplicates by matching email (case-insensitive)", () => {
const existing: ContactCard[] = [
{
id: "existing-1",
addressBookIds: {},
emails: { e0: { address: "Alice@Example.com" } },
},
];
const incoming: ContactCard[] = [
{
id: "new-1",
addressBookIds: {},
emails: { e0: { address: "alice@example.com" } },
},
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.size).toBe(1);
expect(dupes.get(0)).toBe("existing-1");
});
it("returns empty map when no duplicates", () => {
const existing: ContactCard[] = [
{
id: "existing-1",
addressBookIds: {},
emails: { e0: { address: "alice@example.com" } },
},
];
const incoming: ContactCard[] = [
{
id: "new-1",
addressBookIds: {},
emails: { e0: { address: "bob@example.com" } },
},
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.size).toBe(0);
});
it("handles contacts without emails", () => {
const existing: ContactCard[] = [
{ id: "existing-1", addressBookIds: {} },
];
const incoming: ContactCard[] = [
{ id: "new-1", addressBookIds: {} },
{
id: "new-2",
addressBookIds: {},
emails: { e0: { address: "a@b.com" } },
},
];
const dupes = detectDuplicates(existing, incoming);
expect(dupes.size).toBe(0);
});
});