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);
});
});
+110 -1
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse } from "./types";
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
@@ -866,6 +866,61 @@ export class JMAPClient {
}
}
async advancedSearchEmails(
filter: Record<string, unknown>,
accountId?: string,
limit: number = 50,
position: number = 0
): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter,
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
}, "0"],
["Email/get", {
accountId: targetAccountId,
"#ids": {
resultOf: "0",
name: "Email/query",
path: "/ids",
},
properties: [
"id",
"threadId",
"mailboxIds",
"keywords",
"size",
"receivedAt",
"from",
"to",
"cc",
"subject",
"preview",
"hasAttachment",
],
}, "1"],
]);
const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || [];
const total = queryResponse?.total || 0;
const hasMore = total > 0
? (position + emails.length) < total
: emails.length === limit;
return { emails, hasMore, total };
} catch (error) {
console.error('Advanced search failed:', error);
throw error;
}
}
// Thread methods for conversation view
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
try {
@@ -1090,6 +1145,60 @@ export class JMAPClient {
throw new Error("Failed to delete identity: Server response was unexpected. Check server logs.");
}
private vacationUsing(): string[] {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"];
}
async getVacationResponse(): Promise<VacationResponse> {
const response = await this.request([
["VacationResponse/get", {
accountId: this.accountId,
ids: ["singleton"],
}, "0"]
], this.vacationUsing());
if (response.methodResponses?.[0]?.[0] === "VacationResponse/get") {
const list = response.methodResponses[0][1].list || [];
if (list.length > 0) {
return list[0] as VacationResponse;
}
return {
id: "singleton",
isEnabled: false,
fromDate: null,
toDate: null,
subject: "",
textBody: "",
htmlBody: null,
};
}
throw new Error("Failed to fetch vacation response: unexpected server response");
}
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
const response = await this.request([
["VacationResponse/set", {
accountId: this.accountId,
update: {
"singleton": updates,
},
}, "0"]
], this.vacationUsing());
if (response.methodResponses?.[0]?.[0] === "VacationResponse/set") {
const result = response.methodResponses[0][1];
if (result.notUpdated?.["singleton"]) {
const error = result.notUpdated["singleton"];
throw new Error(error.description || "Failed to update vacation response");
}
return;
}
throw new Error("Failed to update vacation response");
}
async createDraft(
to: string[],
subject: string,
+129
View File
@@ -0,0 +1,129 @@
export interface SearchFilters {
from: string;
to: string;
subject: string;
body: string;
hasAttachment: boolean | null;
dateAfter: string;
dateBefore: string;
isUnread: boolean | null;
isStarred: boolean | null;
}
export const DEFAULT_SEARCH_FILTERS: SearchFilters = {
from: "",
to: "",
subject: "",
body: "",
hasAttachment: null,
dateAfter: "",
dateBefore: "",
isUnread: null,
isStarred: null,
};
export function buildJMAPFilter(
textQuery: string,
filters: SearchFilters,
mailboxId?: string
): Record<string, unknown> {
const conditions: Record<string, unknown>[] = [];
if (textQuery) {
conditions.push({ text: textQuery });
}
if (filters.from) {
conditions.push({ from: filters.from });
}
if (filters.to) {
conditions.push({ to: filters.to });
}
if (filters.subject) {
conditions.push({ subject: filters.subject });
}
if (filters.body) {
conditions.push({ body: filters.body });
}
if (filters.hasAttachment === true) {
conditions.push({ hasAttachment: true });
} else if (filters.hasAttachment === false) {
conditions.push({ hasAttachment: false });
}
if (filters.dateAfter) {
const date = new Date(filters.dateAfter);
if (!isNaN(date.getTime())) {
conditions.push({ after: date.toISOString() });
}
}
if (filters.dateBefore) {
const endOfDay = new Date(filters.dateBefore);
if (!isNaN(endOfDay.getTime())) {
endOfDay.setHours(23, 59, 59, 999);
conditions.push({ before: endOfDay.toISOString() });
}
}
if (filters.isUnread === true) {
conditions.push({ notKeyword: "$seen" });
} else if (filters.isUnread === false) {
conditions.push({ hasKeyword: "$seen" });
}
if (filters.isStarred === true) {
conditions.push({ hasKeyword: "$flagged" });
} else if (filters.isStarred === false) {
conditions.push({ notKeyword: "$flagged" });
}
if (mailboxId) {
conditions.push({ inMailbox: mailboxId });
}
if (conditions.length === 0) {
return mailboxId ? { inMailbox: mailboxId } : {};
}
if (conditions.length === 1) {
return conditions[0];
}
return {
operator: "AND",
conditions,
};
}
export function isFilterEmpty(filters: SearchFilters): boolean {
return (
!filters.from &&
!filters.to &&
!filters.subject &&
!filters.body &&
filters.hasAttachment === null &&
!filters.dateAfter &&
!filters.dateBefore &&
filters.isUnread === null &&
filters.isStarred === null
);
}
export function activeFilterCount(filters: SearchFilters): number {
let count = 0;
if (filters.from) count++;
if (filters.to) count++;
if (filters.subject) count++;
if (filters.body) count++;
if (filters.hasAttachment !== null) count++;
if (filters.dateAfter) count++;
if (filters.dateBefore) count++;
if (filters.isUnread !== null) count++;
if (filters.isStarred !== null) count++;
return count;
}
+11
View File
@@ -167,6 +167,7 @@ export interface ContactCard {
addresses?: Record<string, ContactAddress>;
nicknames?: Record<string, ContactNickname>;
notes?: Record<string, ContactNote>;
members?: Record<string, boolean>;
created?: string;
updated?: string;
}
@@ -233,6 +234,16 @@ export interface AddressBookRights {
mayDelete: boolean;
}
export interface VacationResponse {
id: string;
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
}
export interface EmailSubmission {
id: string;
identityId: string;
+62
View File
@@ -0,0 +1,62 @@
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
const LEVELS: Record<LogLevel, number> = { error: 0, warn: 1, info: 2, debug: 3 };
const COLORS: Record<LogLevel, string> = {
error: '\x1b[31m',
warn: '\x1b[33m',
info: '\x1b[34m',
debug: '\x1b[90m',
};
const RESET = '\x1b[0m';
function getLevel(): number {
const env = process.env.LOG_LEVEL?.toLowerCase() as LogLevel | undefined;
return env && env in LEVELS ? LEVELS[env] : LEVELS.info;
}
function isJson(): boolean {
return process.env.LOG_FORMAT?.toLowerCase() === 'json';
}
function log(level: LogLevel, message: string, extra?: Record<string, unknown>): void {
if (LEVELS[level] > getLevel()) return;
if (isJson()) {
const entry: Record<string, unknown> = {
timestamp: new Date().toISOString(),
level,
message,
...extra,
};
const out = JSON.stringify(entry);
if (level === 'error' || level === 'warn') {
console.error(out);
} else {
console.log(out);
}
return;
}
const color = COLORS[level];
const tag = `${color}[${level.toUpperCase().padEnd(5)}]${RESET}`;
const ts = new Date().toISOString();
const suffix = extra && Object.keys(extra).length > 0
? ` ${COLORS.debug}${JSON.stringify(extra)}${RESET}`
: '';
if (level === 'error' || level === 'warn') {
console.error(`${tag} ${ts} ${message}${suffix}`);
} else {
console.log(`${tag} ${ts} ${message}${suffix}`);
}
}
export const logger = {
error: (message: string, extra?: Record<string, unknown>) => log('error', message, extra),
warn: (message: string, extra?: Record<string, unknown>) => log('warn', message, extra),
info: (message: string, extra?: Record<string, unknown>) => log('info', message, extra),
debug: (message: string, extra?: Record<string, unknown>) => log('debug', message, extra),
request: (method: string, path: string, status: number, durationMs: number) =>
log('info', `${method} ${path} ${status}`, { method, path, status, durationMs }),
};
+346
View File
@@ -0,0 +1,346 @@
import type { ContactCard, NameComponent } from "@/lib/jmap/types";
function unfoldLines(vcf: string): string {
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function decodeValue(raw: string): string {
return raw
.replace(/\\n/gi, "\n")
.replace(/\\,/g, ",")
.replace(/\\;/g, ";")
.replace(/\\\\/g, "\\");
}
function encodeValue(val: string): string {
return val
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n");
}
function parseParams(paramStr: string): Record<string, string> {
const params: Record<string, string> = {};
if (!paramStr) return params;
const parts = paramStr.split(";");
for (const part of parts) {
const eq = part.indexOf("=");
if (eq > 0) {
params[part.substring(0, eq).toUpperCase()] = part.substring(eq + 1).replace(/"/g, "");
} else {
const upper = part.toUpperCase();
if (["WORK", "HOME", "CELL", "FAX", "VOICE", "PREF"].includes(upper)) {
params.TYPE = params.TYPE ? `${params.TYPE},${upper}` : upper;
}
}
}
return params;
}
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
if (!typeStr) return undefined;
const types = typeStr.toUpperCase().split(",");
const ctx: Record<string, boolean> = {};
if (types.includes("WORK")) ctx.work = true;
if (types.includes("HOME")) ctx.private = true;
if (!ctx.work && !ctx.private) return undefined;
return ctx;
}
function contextToType(contexts: Record<string, boolean> | undefined): string {
if (!contexts) return "";
if (contexts.work) return "WORK";
if (contexts.private) return "HOME";
return "";
}
export function parseVCard(vcfString: string): ContactCard[] {
const text = unfoldLines(vcfString);
const lines = text.split("\n");
const contacts: ContactCard[] = [];
let current: Record<string, string[]> | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.toUpperCase() === "BEGIN:VCARD") {
current = {};
continue;
}
if (trimmed.toUpperCase() === "END:VCARD") {
if (current) {
const card = buildContact(current);
if (card) contacts.push(card);
}
current = null;
continue;
}
if (current) {
const colonIdx = trimmed.indexOf(":");
if (colonIdx < 1) continue;
const keyPart = trimmed.substring(0, colonIdx);
const value = trimmed.substring(colonIdx + 1);
if (!current[keyPart]) current[keyPart] = [];
current[keyPart].push(value);
}
}
return contacts;
}
function buildContact(raw: Record<string, string[]>): ContactCard | null {
const id = `import-${crypto.randomUUID()}`;
const card: ContactCard = { id, addressBookIds: {} };
for (const [fullKey, values] of Object.entries(raw)) {
const semiIdx = fullKey.indexOf(";");
const propName = (semiIdx > 0 ? fullKey.substring(0, semiIdx) : fullKey).toUpperCase();
const paramStr = semiIdx > 0 ? fullKey.substring(semiIdx + 1) : "";
const params = parseParams(paramStr);
for (const rawValue of values) {
const val = decodeValue(rawValue);
switch (propName) {
case "FN":
if (!card.name) {
const parts = val.split(" ");
const components: NameComponent[] = [];
if (parts.length >= 2) {
components.push({ kind: "given", value: parts[0] });
components.push({ kind: "surname", value: parts.slice(1).join(" ") });
} else if (parts.length === 1) {
components.push({ kind: "given", value: parts[0] });
}
card.name = { components, isOrdered: true };
}
break;
case "N": {
const nParts = val.split(";");
const components: NameComponent[] = [];
if (nParts[3]) components.push({ kind: "prefix", value: nParts[3] });
if (nParts[1]) components.push({ kind: "given", value: nParts[1] });
if (nParts[2]) components.push({ kind: "additional", value: nParts[2] });
if (nParts[0]) components.push({ kind: "surname", value: nParts[0] });
if (nParts[4]) components.push({ kind: "suffix", value: nParts[4] });
if (components.length > 0) {
card.name = { components, isOrdered: true };
}
break;
}
case "EMAIL": {
if (!card.emails) card.emails = {};
const idx = Object.keys(card.emails).length;
card.emails[`e${idx}`] = {
address: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "TEL": {
if (!card.phones) card.phones = {};
const idx = Object.keys(card.phones).length;
card.phones[`p${idx}`] = {
number: val,
contexts: typeToContext(params.TYPE),
};
break;
}
case "ORG": {
if (!card.organizations) card.organizations = {};
const orgParts = val.split(";").filter(Boolean);
const idx = Object.keys(card.organizations).length;
card.organizations[`o${idx}`] = {
name: orgParts[0],
units: orgParts.slice(1).map(u => ({ name: u })),
};
break;
}
case "ADR": {
if (!card.addresses) card.addresses = {};
const adrParts = val.split(";");
const idx = Object.keys(card.addresses).length;
card.addresses[`a${idx}`] = {
street: adrParts[2] || undefined,
locality: adrParts[3] || undefined,
region: adrParts[4] || undefined,
postcode: adrParts[5] || undefined,
country: adrParts[6] || undefined,
contexts: typeToContext(params.TYPE),
};
break;
}
case "NOTE": {
if (!card.notes) card.notes = {};
const idx = Object.keys(card.notes).length;
card.notes[`n${idx}`] = { note: val };
break;
}
case "NICKNAME": {
if (!card.nicknames) card.nicknames = {};
card.nicknames.n0 = { name: val };
break;
}
case "UID":
card.uid = val;
break;
case "KIND": {
const k = val.toLowerCase();
if (k === "group" || k === "individual" || k === "org") {
card.kind = k;
}
break;
}
case "MEMBER": {
if (!card.members) card.members = {};
const memberUri = val.startsWith("urn:uuid:") ? val.substring(9) : val;
card.members[memberUri] = true;
break;
}
}
}
}
const hasName = card.name && card.name.components.length > 0;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
return card;
}
export function generateVCard(contacts: ContactCard[]): string {
return contacts.map(generateSingleVCard).join("\r\n");
}
function generateSingleVCard(contact: ContactCard): string {
const lines: string[] = ["BEGIN:VCARD", "VERSION:3.0"];
if (contact.uid) {
lines.push(`UID:${contact.uid}`);
}
if (contact.kind) {
lines.push(`KIND:${contact.kind}`);
}
const components = contact.name?.components || [];
const given = components.find(c => c.kind === "given")?.value || "";
const surname = components.find(c => c.kind === "surname")?.value || "";
const prefix = components.find(c => c.kind === "prefix")?.value || "";
const suffix = components.find(c => c.kind === "suffix")?.value || "";
const additional = components.find(c => c.kind === "additional")?.value || "";
const fn = [given, surname].filter(Boolean).join(" ");
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
}
if (contact.nicknames) {
for (const nick of Object.values(contact.nicknames)) {
lines.push(`NICKNAME:${encodeValue(nick.name)}`);
}
}
if (contact.emails) {
for (const email of Object.values(contact.emails)) {
const type = contextToType(email.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`EMAIL${typeParam}:${email.address}`);
}
}
if (contact.phones) {
for (const phone of Object.values(contact.phones)) {
const type = contextToType(phone.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
lines.push(`TEL${typeParam}:${phone.number}`);
}
}
if (contact.organizations) {
for (const org of Object.values(contact.organizations)) {
const parts = [org.name || ""];
if (org.units) parts.push(...org.units.map(u => u.name));
lines.push(`ORG:${parts.map(encodeValue).join(";")}`);
}
}
if (contact.addresses) {
for (const addr of Object.values(contact.addresses)) {
const type = contextToType(addr.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
const parts = [
"",
"",
addr.street || "",
addr.locality || "",
addr.region || "",
addr.postcode || "",
addr.country || "",
];
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
}
}
if (contact.notes) {
for (const n of Object.values(contact.notes)) {
lines.push(`NOTE:${encodeValue(n.note)}`);
}
}
if (contact.members) {
for (const memberId of Object.keys(contact.members)) {
if (contact.members[memberId]) {
lines.push(`MEMBER:urn:uuid:${memberId}`);
}
}
}
lines.push("END:VCARD");
return lines.join("\r\n");
}
export function detectDuplicates(
existing: ContactCard[],
incoming: ContactCard[]
): Map<number, string> {
const dupes = new Map<number, string>();
const existingEmails = new Map<string, string>();
for (const c of existing) {
if (c.emails) {
for (const e of Object.values(c.emails)) {
existingEmails.set(e.address.toLowerCase(), c.id);
}
}
}
incoming.forEach((card, idx) => {
if (card.emails) {
for (const e of Object.values(card.emails)) {
const match = existingEmails.get(e.address.toLowerCase());
if (match) {
dupes.set(idx, match);
return;
}
}
}
});
return dupes;
}