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:
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user