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

- Contact groups/lists, vCard import/export (RFC 6350), bulk operations
- Advanced search with JMAP filter panel, search chips, cross-mailbox queries
- Vacation responder with JMAP VacationResponse, settings tab, sidebar indicator
- TOTP two-factor authentication support
- Docker multi-stage build with standalone output and docker-compose
- CSP Report-Only headers and security headers via proxy middleware
- Virtual scrolling for large email lists
- Structured server-side logger (text/JSON, configurable level)
- 450+ tests (contacts, vCard, threads, headers, identity, components)
- Playwright E2E framework setup
- Updated README and ROADMAP with all new features
This commit is contained in:
Matthieu MALVACHE
2026-02-16 18:51:30 +01:00
committed by Matthieu MALVACHE
parent 8a5bc9b88d
commit fb414bf2c9
70 changed files with 8160 additions and 547 deletions
+516
View File
@@ -0,0 +1,516 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useContactStore } from '../contact-store';
import type { ContactCard } from '@/lib/jmap/types';
vi.stubGlobal('crypto', { randomUUID: () => '00000000-0000-0000-0000-000000000000' });
const makeContact = (overrides: Partial<ContactCard> = {}): ContactCard => ({
id: 'contact-1',
addressBookIds: { 'ab-1': true },
name: { components: [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }], isOrdered: true },
emails: { e0: { address: 'john@example.com' } },
...overrides,
});
const makeGroup = (overrides: Partial<ContactCard> = {}): ContactCard => ({
id: 'group-1',
addressBookIds: {},
kind: 'group',
name: { components: [{ kind: 'given', value: 'Team' }], isOrdered: true },
members: {},
...overrides,
});
const defaultState = {
contacts: [],
addressBooks: [],
selectedContactId: null,
searchQuery: '',
isLoading: false,
error: null,
supportsSync: false,
selectedContactIds: new Set<string>(),
activeTab: 'all' as const,
};
describe('contact-store', () => {
beforeEach(() => {
useContactStore.setState(defaultState);
});
describe('addLocalContact', () => {
it('should append contact to array', () => {
useContactStore.getState().addLocalContact(makeContact());
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].id).toBe('contact-1');
});
it('should preserve existing contacts', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
expect(useContactStore.getState().contacts).toHaveLength(2);
});
});
describe('updateLocalContact', () => {
it('should update matching contact', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().updateLocalContact('c1', {
emails: { e0: { address: 'updated@example.com' } },
});
expect(useContactStore.getState().contacts[0].emails!.e0.address).toBe('updated@example.com');
});
it('should not modify other contacts', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2', emails: { e0: { address: 'c2@test.com' } } }));
useContactStore.getState().updateLocalContact('c1', { emails: { e0: { address: 'new@test.com' } } });
expect(useContactStore.getState().contacts[1].emails!.e0.address).toBe('c2@test.com');
});
it('should no-op for non-existent id', () => {
useContactStore.getState().addLocalContact(makeContact());
useContactStore.getState().updateLocalContact('nonexistent', { kind: 'org' });
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].kind).toBeUndefined();
});
});
describe('deleteLocalContact', () => {
it('should remove contact by id', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
useContactStore.getState().deleteLocalContact('c1');
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].id).toBe('c2');
});
it('should clear selectedContactId when deleting selected', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().setSelectedContact('c1');
useContactStore.getState().deleteLocalContact('c1');
expect(useContactStore.getState().selectedContactId).toBeNull();
});
it('should preserve selectedContactId when deleting other', () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().addLocalContact(makeContact({ id: 'c2' }));
useContactStore.getState().setSelectedContact('c1');
useContactStore.getState().deleteLocalContact('c2');
expect(useContactStore.getState().selectedContactId).toBe('c1');
});
});
describe('setSelectedContact', () => {
it('should set selectedContactId', () => {
useContactStore.getState().setSelectedContact('c1');
expect(useContactStore.getState().selectedContactId).toBe('c1');
});
it('should allow null to deselect', () => {
useContactStore.getState().setSelectedContact('c1');
useContactStore.getState().setSelectedContact(null);
expect(useContactStore.getState().selectedContactId).toBeNull();
});
});
describe('setSearchQuery', () => {
it('should set search query', () => {
useContactStore.getState().setSearchQuery('john');
expect(useContactStore.getState().searchQuery).toBe('john');
});
});
describe('setSupportsSync', () => {
it('should set supportsSync flag', () => {
useContactStore.getState().setSupportsSync(true);
expect(useContactStore.getState().supportsSync).toBe(true);
});
});
describe('setActiveTab', () => {
it('should set active tab', () => {
useContactStore.getState().setActiveTab('groups');
expect(useContactStore.getState().activeTab).toBe('groups');
});
});
describe('clearContacts', () => {
it('should reset all contact-related state', () => {
useContactStore.setState({
contacts: [makeContact()],
addressBooks: [{ id: 'ab-1', name: 'Default', isDefault: true }],
selectedContactId: 'c1',
searchQuery: 'test',
error: 'some error',
selectedContactIds: new Set(['c1', 'c2']),
activeTab: 'groups',
});
useContactStore.getState().clearContacts();
const state = useContactStore.getState();
expect(state.contacts).toEqual([]);
expect(state.addressBooks).toEqual([]);
expect(state.selectedContactId).toBeNull();
expect(state.searchQuery).toBe('');
expect(state.error).toBeNull();
expect(state.selectedContactIds.size).toBe(0);
expect(state.activeTab).toBe('all');
});
});
describe('toggleContactSelection', () => {
it('should add id to selection', () => {
useContactStore.getState().toggleContactSelection('c1');
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(true);
});
it('should remove id when already selected', () => {
useContactStore.getState().toggleContactSelection('c1');
useContactStore.getState().toggleContactSelection('c1');
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
});
it('should handle multiple selections independently', () => {
useContactStore.getState().toggleContactSelection('c1');
useContactStore.getState().toggleContactSelection('c2');
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
useContactStore.getState().toggleContactSelection('c1');
expect(useContactStore.getState().selectedContactIds.has('c1')).toBe(false);
expect(useContactStore.getState().selectedContactIds.has('c2')).toBe(true);
});
});
describe('selectAllContacts', () => {
it('should set all provided ids', () => {
useContactStore.getState().selectAllContacts(['c1', 'c2', 'c3']);
expect(useContactStore.getState().selectedContactIds.size).toBe(3);
});
it('should replace previous selection', () => {
useContactStore.getState().toggleContactSelection('c0');
useContactStore.getState().selectAllContacts(['c1', 'c2']);
expect(useContactStore.getState().selectedContactIds.has('c0')).toBe(false);
expect(useContactStore.getState().selectedContactIds.size).toBe(2);
});
});
describe('clearSelection', () => {
it('should empty the selection set', () => {
useContactStore.getState().selectAllContacts(['c1', 'c2']);
useContactStore.getState().clearSelection();
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
});
});
describe('getAutocomplete', () => {
beforeEach(() => {
useContactStore.setState({
contacts: [
makeContact({ id: 'c1' }),
makeContact({ id: 'c2', name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Smith' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
makeContact({ id: 'c3', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.org' } } }),
],
});
});
it('should return empty for empty query', () => {
expect(useContactStore.getState().getAutocomplete('')).toEqual([]);
});
it('should match by name', () => {
const results = useContactStore.getState().getAutocomplete('john');
expect(results).toHaveLength(1);
expect(results[0].email).toBe('john@example.com');
});
it('should match by email address', () => {
const results = useContactStore.getState().getAutocomplete('test.org');
expect(results).toHaveLength(1);
expect(results[0].name).toBe('Bob');
});
it('should be case insensitive', () => {
const results = useContactStore.getState().getAutocomplete('JANE');
expect(results).toHaveLength(1);
expect(results[0].email).toBe('jane@example.com');
});
it('should cap results at 10', () => {
const manyContacts = Array.from({ length: 15 }, (_, i) =>
makeContact({ id: `c${i}`, name: { components: [{ kind: 'given', value: `User${i}` }], isOrdered: true }, emails: { e0: { address: `user${i}@test.com` } } })
);
useContactStore.setState({ contacts: manyContacts });
const results = useContactStore.getState().getAutocomplete('user');
expect(results.length).toBeLessThanOrEqual(10);
});
it('should expand group members when group name matches', () => {
const member1 = makeContact({ id: 'm1', name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true }, emails: { e0: { address: 'alice@test.com' } } });
const member2 = makeContact({ id: 'm2', name: { components: [{ kind: 'given', value: 'Bob' }], isOrdered: true }, emails: { e0: { address: 'bob@test.com' } } });
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
useContactStore.setState({ contacts: [member1, member2, group] });
const results = useContactStore.getState().getAutocomplete('Team');
expect(results).toHaveLength(2);
expect(results.map(r => r.email).sort()).toEqual(['alice@test.com', 'bob@test.com']);
});
it('should not include group itself in results', () => {
const group = makeGroup({ id: 'g1' });
useContactStore.setState({ contacts: [group] });
const results = useContactStore.getState().getAutocomplete('Team');
expect(results).toHaveLength(0);
});
it('should return contacts with multiple emails as separate results', () => {
const multi = makeContact({
id: 'multi',
name: { components: [{ kind: 'given', value: 'Multi' }], isOrdered: true },
emails: { e0: { address: 'a@test.com' }, e1: { address: 'b@test.com' } },
});
useContactStore.setState({ contacts: [multi] });
const results = useContactStore.getState().getAutocomplete('Multi');
expect(results).toHaveLength(2);
});
});
describe('getGroups', () => {
it('should return only group contacts', () => {
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
const groups = useContactStore.getState().getGroups();
expect(groups).toHaveLength(1);
expect(groups[0].kind).toBe('group');
});
it('should return empty when no groups', () => {
useContactStore.setState({ contacts: [makeContact()] });
expect(useContactStore.getState().getGroups()).toHaveLength(0);
});
});
describe('getIndividuals', () => {
it('should return non-group contacts', () => {
useContactStore.setState({ contacts: [makeContact(), makeGroup()] });
const individuals = useContactStore.getState().getIndividuals();
expect(individuals).toHaveLength(1);
expect(individuals[0].kind).not.toBe('group');
});
it('should include org and undefined kind', () => {
useContactStore.setState({
contacts: [
makeContact({ id: 'c1' }),
makeContact({ id: 'c2', kind: 'org' }),
makeGroup(),
],
});
expect(useContactStore.getState().getIndividuals()).toHaveLength(2);
});
});
describe('getGroupMembers', () => {
it('should return contacts whose ids are in group members', () => {
const m1 = makeContact({ id: 'm1' });
const m2 = makeContact({ id: 'm2' });
const nonMember = makeContact({ id: 'nm' });
const group = makeGroup({ id: 'g1', members: { m1: true, m2: true } });
useContactStore.setState({ contacts: [m1, m2, nonMember, group] });
const members = useContactStore.getState().getGroupMembers('g1');
expect(members).toHaveLength(2);
expect(members.map(m => m.id).sort()).toEqual(['m1', 'm2']);
});
it('should return empty for group with no members', () => {
useContactStore.setState({ contacts: [makeGroup({ id: 'g1', members: {} })] });
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(0);
});
it('should return empty for non-existent group', () => {
expect(useContactStore.getState().getGroupMembers('nonexistent')).toHaveLength(0);
});
it('should match by uid as well as id', () => {
const m1 = makeContact({ id: 'm1', uid: 'uid-m1' });
const group = makeGroup({ id: 'g1', members: { 'uid-m1': true } });
useContactStore.setState({ contacts: [m1, group] });
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
});
it('should exclude members with false value', () => {
const m1 = makeContact({ id: 'm1' });
const m2 = makeContact({ id: 'm2' });
const group = makeGroup({ id: 'g1', members: { m1: true, m2: false } });
useContactStore.setState({ contacts: [m1, m2, group] });
expect(useContactStore.getState().getGroupMembers('g1')).toHaveLength(1);
});
});
describe('createGroup (local mode)', () => {
it('should create group with local- prefix id', async () => {
await useContactStore.getState().createGroup(null, 'Friends', ['c1', 'c2']);
const contacts = useContactStore.getState().contacts;
expect(contacts).toHaveLength(1);
expect(contacts[0].id).toMatch(/^local-/);
expect(contacts[0].kind).toBe('group');
});
it('should set members from provided ids', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2']);
const group = useContactStore.getState().contacts[0];
expect(group.members).toEqual({ m1: true, m2: true });
});
it('should set group name', async () => {
await useContactStore.getState().createGroup(null, 'Work', []);
const group = useContactStore.getState().contacts[0];
expect(group.name?.components?.[0]?.value).toBe('Work');
});
});
describe('updateGroup (local mode)', () => {
it('should update group name', async () => {
await useContactStore.getState().createGroup(null, 'Old', []);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().updateGroup(null, groupId, 'New');
expect(useContactStore.getState().contacts[0].name?.components?.[0]?.value).toBe('New');
});
it('should preserve group members when renaming', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().updateGroup(null, groupId, 'Renamed');
expect(useContactStore.getState().contacts[0].members).toEqual({ m1: true });
});
});
describe('addMembersToGroup (local mode)', () => {
it('should add new members to group', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1']);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().addMembersToGroup(null, groupId, ['m2', 'm3']);
const members = useContactStore.getState().contacts[0].members;
expect(members).toEqual({ m1: true, m2: true, m3: true });
});
it('should no-op for non-existent group', async () => {
await useContactStore.getState().addMembersToGroup(null, 'nonexistent', ['m1']);
expect(useContactStore.getState().contacts).toHaveLength(0);
});
});
describe('removeMembersFromGroup (local mode)', () => {
it('should remove members from group', async () => {
await useContactStore.getState().createGroup(null, 'Team', ['m1', 'm2', 'm3']);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().removeMembersFromGroup(null, groupId, ['m2']);
const members = useContactStore.getState().contacts[0].members;
expect(members).toEqual({ m1: true, m3: true });
});
it('should no-op for group without members', async () => {
const group = makeGroup({ id: 'g1', members: undefined });
useContactStore.setState({ contacts: [group] });
await useContactStore.getState().removeMembersFromGroup(null, 'g1', ['m1']);
expect(useContactStore.getState().contacts[0].members).toBeUndefined();
});
});
describe('deleteGroup (local mode)', () => {
it('should remove group from contacts', async () => {
await useContactStore.getState().createGroup(null, 'Team', []);
const groupId = useContactStore.getState().contacts[0].id;
await useContactStore.getState().deleteGroup(null, groupId);
expect(useContactStore.getState().contacts).toHaveLength(0);
});
it('should clear selectedContactId when deleting selected group', async () => {
await useContactStore.getState().createGroup(null, 'Team', []);
const groupId = useContactStore.getState().contacts[0].id;
useContactStore.getState().setSelectedContact(groupId);
await useContactStore.getState().deleteGroup(null, groupId);
expect(useContactStore.getState().selectedContactId).toBeNull();
});
it('should preserve selectedContactId when deleting other group', async () => {
useContactStore.getState().addLocalContact(makeContact({ id: 'c1' }));
useContactStore.getState().setSelectedContact('c1');
await useContactStore.getState().createGroup(null, 'Team', []);
const groupId = useContactStore.getState().contacts[1].id;
await useContactStore.getState().deleteGroup(null, groupId);
expect(useContactStore.getState().selectedContactId).toBe('c1');
});
});
describe('bulkDeleteContacts (local mode)', () => {
it('should remove multiple contacts', async () => {
useContactStore.setState({
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' }), makeContact({ id: 'c3' })],
});
await useContactStore.getState().bulkDeleteContacts(null, ['c1', 'c3']);
expect(useContactStore.getState().contacts).toHaveLength(1);
expect(useContactStore.getState().contacts[0].id).toBe('c2');
});
it('should clear selection after bulk delete', async () => {
useContactStore.setState({
contacts: [makeContact({ id: 'c1' })],
selectedContactIds: new Set(['c1']),
});
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
});
it('should clear selectedContactId if deleted', async () => {
useContactStore.setState({
contacts: [makeContact({ id: 'c1' }), makeContact({ id: 'c2' })],
selectedContactId: 'c1',
});
await useContactStore.getState().bulkDeleteContacts(null, ['c1']);
expect(useContactStore.getState().selectedContactId).toBeNull();
});
});
describe('bulkAddToGroup (local mode)', () => {
it('should add contacts to group and clear selection', async () => {
const m1 = makeContact({ id: 'm1' });
const group = makeGroup({ id: 'g1', members: {} });
useContactStore.setState({
contacts: [m1, group],
selectedContactIds: new Set(['m1']),
});
await useContactStore.getState().bulkAddToGroup(null, 'g1', ['m1']);
expect(useContactStore.getState().contacts.find(c => c.id === 'g1')?.members).toEqual({ m1: true });
expect(useContactStore.getState().selectedContactIds.size).toBe(0);
});
});
describe('importContacts (local mode)', () => {
it('should import contacts with local- prefix ids', async () => {
const toImport = [makeContact({ id: 'orig-1' }), makeContact({ id: 'orig-2' })];
const count = await useContactStore.getState().importContacts(null, toImport);
expect(count).toBe(2);
expect(useContactStore.getState().contacts).toHaveLength(2);
expect(useContactStore.getState().contacts[0].id).toMatch(/^local-/);
});
});
describe('persistence/partialize', () => {
it('should persist contacts when supportsSync is false', () => {
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
const state = { contacts: [makeContact()], supportsSync: false };
const persisted = partialize(state);
expect(persisted.contacts).toHaveLength(1);
expect(persisted.supportsSync).toBe(false);
});
it('should persist empty contacts array when supportsSync is true', () => {
const { partialize } = (useContactStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
const state = { contacts: [makeContact()], supportsSync: true };
const persisted = partialize(state);
expect(persisted.contacts).toEqual([]);
expect(persisted.supportsSync).toBe(true);
});
});
});
+257
View File
@@ -0,0 +1,257 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useIdentityStore } from '../identity-store';
import type { Identity } from '@/lib/jmap/types';
const makeIdentity = (overrides: Partial<Identity> = {}): Identity => ({
id: 'id-1',
name: 'Test User',
email: 'test@example.com',
mayDelete: true,
...overrides,
});
describe('identity-store', () => {
beforeEach(() => {
useIdentityStore.setState({
identities: [],
selectedIdentityId: null,
isLoading: false,
error: null,
subAddress: { recentTags: [], tagSuggestions: {} },
});
});
describe('setIdentities', () => {
it('should set identities list', () => {
const identities = [makeIdentity(), makeIdentity({ id: 'id-2', email: 'other@test.com' })];
useIdentityStore.getState().setIdentities(identities);
expect(useIdentityStore.getState().identities).toHaveLength(2);
});
it('should replace existing identities', () => {
useIdentityStore.getState().setIdentities([makeIdentity()]);
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-new' })]);
expect(useIdentityStore.getState().identities).toHaveLength(1);
expect(useIdentityStore.getState().identities[0].id).toBe('id-new');
});
});
describe('addIdentity', () => {
it('should append identity to list', () => {
useIdentityStore.getState().addIdentity(makeIdentity());
expect(useIdentityStore.getState().identities).toHaveLength(1);
});
it('should not remove existing identities', () => {
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-1' }));
useIdentityStore.getState().addIdentity(makeIdentity({ id: 'id-2' }));
expect(useIdentityStore.getState().identities).toHaveLength(2);
});
});
describe('updateIdentityLocal', () => {
it('should update matching identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1', name: 'Old' })]);
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'New' });
expect(useIdentityStore.getState().identities[0].name).toBe('New');
});
it('should not modify other identities', () => {
useIdentityStore.getState().setIdentities([
makeIdentity({ id: 'id-1', name: 'First' }),
makeIdentity({ id: 'id-2', name: 'Second' }),
]);
useIdentityStore.getState().updateIdentityLocal('id-1', { name: 'Updated' });
expect(useIdentityStore.getState().identities[1].name).toBe('Second');
});
it('should no-op for non-existent identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity()]);
useIdentityStore.getState().updateIdentityLocal('nonexistent', { name: 'X' });
expect(useIdentityStore.getState().identities[0].name).toBe('Test User');
});
});
describe('removeIdentity', () => {
it('should remove identity by id', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
useIdentityStore.getState().removeIdentity('id-1');
expect(useIdentityStore.getState().identities).toHaveLength(1);
expect(useIdentityStore.getState().identities[0].id).toBe('id-2');
});
it('should clear selectedIdentityId when removing selected identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' })]);
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().removeIdentity('id-1');
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
});
it('should preserve selectedIdentityId when removing different identity', () => {
useIdentityStore.getState().setIdentities([makeIdentity({ id: 'id-1' }), makeIdentity({ id: 'id-2' })]);
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().removeIdentity('id-2');
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
});
});
describe('selectIdentity', () => {
it('should set selectedIdentityId', () => {
useIdentityStore.getState().selectIdentity('id-1');
expect(useIdentityStore.getState().selectedIdentityId).toBe('id-1');
});
it('should allow null to deselect', () => {
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().selectIdentity(null);
expect(useIdentityStore.getState().selectedIdentityId).toBeNull();
});
});
describe('setLoading', () => {
it('should set loading state', () => {
useIdentityStore.getState().setLoading(true);
expect(useIdentityStore.getState().isLoading).toBe(true);
});
it('should clear loading state', () => {
useIdentityStore.getState().setLoading(true);
useIdentityStore.getState().setLoading(false);
expect(useIdentityStore.getState().isLoading).toBe(false);
});
});
describe('setError', () => {
it('should set error message', () => {
useIdentityStore.getState().setError('Something went wrong');
expect(useIdentityStore.getState().error).toBe('Something went wrong');
});
it('should clear error with null', () => {
useIdentityStore.getState().setError('error');
useIdentityStore.getState().setError(null);
expect(useIdentityStore.getState().error).toBeNull();
});
});
describe('clearIdentities', () => {
it('should clear identities and selection', () => {
useIdentityStore.getState().setIdentities([makeIdentity()]);
useIdentityStore.getState().selectIdentity('id-1');
useIdentityStore.getState().setError('old error');
useIdentityStore.getState().clearIdentities();
const state = useIdentityStore.getState();
expect(state.identities).toEqual([]);
expect(state.selectedIdentityId).toBeNull();
expect(state.error).toBeNull();
});
it('should not clear sub-address state', () => {
useIdentityStore.getState().addRecentTag('shopping');
useIdentityStore.getState().clearIdentities();
expect(useIdentityStore.getState().subAddress.recentTags).toContain('shopping');
});
});
describe('addRecentTag', () => {
it('should add tag to recent tags', () => {
useIdentityStore.getState().addRecentTag('shopping');
expect(useIdentityStore.getState().subAddress.recentTags).toEqual(['shopping']);
});
it('should prepend new tags', () => {
useIdentityStore.getState().addRecentTag('first');
useIdentityStore.getState().addRecentTag('second');
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('second');
});
it('should deduplicate tags by moving to front', () => {
useIdentityStore.getState().addRecentTag('a');
useIdentityStore.getState().addRecentTag('b');
useIdentityStore.getState().addRecentTag('a');
const tags = useIdentityStore.getState().subAddress.recentTags;
expect(tags).toEqual(['a', 'b']);
});
it('should cap at 10 recent tags', () => {
for (let i = 0; i < 15; i++) {
useIdentityStore.getState().addRecentTag(`tag-${i}`);
}
expect(useIdentityStore.getState().subAddress.recentTags).toHaveLength(10);
expect(useIdentityStore.getState().subAddress.recentTags[0]).toBe('tag-14');
});
});
describe('addTagSuggestion', () => {
it('should add suggestion for domain', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
});
it('should not duplicate existing suggestion', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
});
it('should cap at 5 suggestions per domain', () => {
for (let i = 0; i < 8; i++) {
useIdentityStore.getState().addTagSuggestion('example.com', `tag-${i}`);
}
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toHaveLength(5);
});
it('should keep suggestions separate per domain', () => {
useIdentityStore.getState().addTagSuggestion('a.com', 'tag-a');
useIdentityStore.getState().addTagSuggestion('b.com', 'tag-b');
expect(useIdentityStore.getState().subAddress.tagSuggestions['a.com']).toEqual(['tag-a']);
expect(useIdentityStore.getState().subAddress.tagSuggestions['b.com']).toEqual(['tag-b']);
});
});
describe('getTagSuggestionsForDomain', () => {
it('should return suggestions for known domain', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
expect(useIdentityStore.getState().getTagSuggestionsForDomain('example.com')).toEqual(['promo']);
});
it('should return empty array for unknown domain', () => {
expect(useIdentityStore.getState().getTagSuggestionsForDomain('unknown.com')).toEqual([]);
});
});
describe('clearRecentTags', () => {
it('should clear recent tags', () => {
useIdentityStore.getState().addRecentTag('a');
useIdentityStore.getState().addRecentTag('b');
useIdentityStore.getState().clearRecentTags();
expect(useIdentityStore.getState().subAddress.recentTags).toEqual([]);
});
it('should not clear tag suggestions', () => {
useIdentityStore.getState().addTagSuggestion('example.com', 'promo');
useIdentityStore.getState().clearRecentTags();
expect(useIdentityStore.getState().subAddress.tagSuggestions['example.com']).toEqual(['promo']);
});
});
describe('persistence', () => {
it('should only persist subAddress state', () => {
const { partialize } = (useIdentityStore as unknown as { persist: { getOptions: () => { partialize: (state: Record<string, unknown>) => Record<string, unknown> } } }).persist.getOptions();
const fullState = {
identities: [makeIdentity()],
selectedIdentityId: 'id-1',
isLoading: true,
error: 'err',
subAddress: { recentTags: ['a'], tagSuggestions: {} },
};
const persisted = partialize(fullState);
expect(persisted).toHaveProperty('subAddress');
expect(persisted).not.toHaveProperty('identities');
expect(persisted).not.toHaveProperty('selectedIdentityId');
expect(persisted).not.toHaveProperty('isLoading');
expect(persisted).not.toHaveProperty('error');
});
});
});
+19 -5
View File
@@ -4,6 +4,7 @@ import { JMAPClient } from '@/lib/jmap/client';
import { useEmailStore } from './email-store';
import { useIdentityStore } from './identity-store';
import { useContactStore } from './contact-store';
import { useVacationStore } from './vacation-store';
import type { Identity } from '@/lib/jmap/types';
interface AuthState {
@@ -16,7 +17,7 @@ interface AuthState {
identities: Identity[];
primaryIdentity: Identity | null;
login: (serverUrl: string, username: string, password: string) => Promise<boolean>;
login: (serverUrl: string, username: string, password: string, totp?: string) => Promise<boolean>;
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
@@ -34,12 +35,13 @@ export const useAuthStore = create<AuthState>()(
identities: [],
primaryIdentity: null,
login: async (serverUrl, username, password) => {
login: async (serverUrl, username, password, totp) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
set({ isLoading: true, error: null });
try {
// Create JMAP client
const client = new JMAPClient(serverUrl, username, password);
const client = new JMAPClient(serverUrl, username, effectivePassword);
// Try to connect
await client.connect();
@@ -55,12 +57,21 @@ export const useAuthStore = create<AuthState>()(
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
contactStore.fetchAddressBooks(client).catch(() => {});
contactStore.fetchContacts(client).catch(() => {});
contactStore.fetchAddressBooks(client).catch((err) => console.error('Failed to fetch address books:', err));
contactStore.fetchContacts(client).catch((err) => console.error('Failed to fetch contacts:', err));
} else {
useContactStore.getState().setSupportsSync(false);
}
// Initialize vacation responder if supported
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
vacationStore.fetchVacationResponse(client).catch((err) => console.error('Failed to fetch vacation response:', err));
} else {
vacationStore.setSupported(false);
}
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
@@ -138,6 +149,9 @@ export const useAuthStore = create<AuthState>()(
// Clear contact store state
useContactStore.getState().clearContacts();
// Clear vacation store state
useVacationStore.getState().clearState();
},
checkAuth: async () => {
+217 -2
View File
@@ -3,7 +3,7 @@ import { persist } from 'zustand/middleware';
import type { ContactCard, AddressBook, ContactName } from '@/lib/jmap/types';
import type { JMAPClient } from '@/lib/jmap/client';
function getContactDisplayName(contact: ContactCard): string {
export function getContactDisplayName(contact: ContactCard): string {
if (contact.name?.components) {
const given = contact.name.components.find(c => c.kind === 'given')?.value || '';
const surname = contact.name.components.find(c => c.kind === 'surname')?.value || '';
@@ -35,6 +35,9 @@ interface ContactStore {
error: string | null;
supportsSync: boolean;
selectedContactIds: Set<string>;
activeTab: 'all' | 'groups';
fetchContacts: (client: JMAPClient) => Promise<void>;
fetchAddressBooks: (client: JMAPClient) => Promise<void>;
createContact: (client: JMAPClient, contact: Partial<ContactCard>) => Promise<void>;
@@ -48,9 +51,27 @@ interface ContactStore {
setSelectedContact: (id: string | null) => void;
setSearchQuery: (query: string) => void;
setSupportsSync: (supports: boolean) => void;
setActiveTab: (tab: 'all' | 'groups') => void;
clearContacts: () => void;
getAutocomplete: (query: string) => Array<{ name: string; email: string }>;
getGroups: () => ContactCard[];
getIndividuals: () => ContactCard[];
getGroupMembers: (groupId: string) => ContactCard[];
createGroup: (client: JMAPClient | null, name: string, memberIds: string[]) => Promise<void>;
updateGroup: (client: JMAPClient | null, groupId: string, name: string) => Promise<void>;
addMembersToGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
removeMembersFromGroup: (client: JMAPClient | null, groupId: string, memberIds: string[]) => Promise<void>;
deleteGroup: (client: JMAPClient | null, groupId: string) => Promise<void>;
toggleContactSelection: (id: string) => void;
selectAllContacts: (ids: string[]) => void;
clearSelection: () => void;
bulkDeleteContacts: (client: JMAPClient | null, ids: string[]) => Promise<void>;
bulkAddToGroup: (client: JMAPClient | null, groupId: string, contactIds: string[]) => Promise<void>;
importContacts: (client: JMAPClient | null, contacts: ContactCard[]) => Promise<number>;
}
export const useContactStore = create<ContactStore>()(
@@ -63,6 +84,8 @@ export const useContactStore = create<ContactStore>()(
isLoading: false,
error: null,
supportsSync: false,
selectedContactIds: new Set<string>(),
activeTab: 'all' as const,
fetchContacts: async (client) => {
set({ isLoading: true, error: null });
@@ -81,6 +104,7 @@ export const useContactStore = create<ContactStore>()(
set({ addressBooks });
} catch (error) {
console.error('Failed to fetch address books:', error);
set({ error: 'Failed to fetch address books' });
}
},
@@ -148,6 +172,7 @@ export const useContactStore = create<ContactStore>()(
setSelectedContact: (id) => set({ selectedContactId: id }),
setSearchQuery: (query) => set({ searchQuery: query }),
setSupportsSync: (supports) => set({ supportsSync: supports }),
setActiveTab: (tab) => set({ activeTab: tab }),
clearContacts: () => set({
contacts: [],
@@ -155,6 +180,8 @@ export const useContactStore = create<ContactStore>()(
selectedContactId: null,
searchQuery: '',
error: null,
selectedContactIds: new Set<string>(),
activeTab: 'all',
}),
getAutocomplete: (query) => {
@@ -165,6 +192,22 @@ export const useContactStore = create<ContactStore>()(
const results: Array<{ name: string; email: string }> = [];
for (const contact of contacts) {
if (contact.kind === 'group') {
const groupName = getContactDisplayName(contact);
if (groupName.toLowerCase().includes(lower)) {
const members = get().getGroupMembers(contact.id);
for (const member of members) {
const memberName = getContactDisplayName(member);
const memberEmails = member.emails ? Object.values(member.emails) : [];
for (const emailEntry of memberEmails) {
if (!emailEntry.address) continue;
results.push({ name: memberName, email: emailEntry.address });
}
}
}
continue;
}
const name = getContactDisplayName(contact);
const emails = contact.emails ? Object.values(contact.emails) : [];
@@ -183,6 +226,178 @@ export const useContactStore = create<ContactStore>()(
return results;
},
getGroups: () => {
return get().contacts.filter(c => c.kind === 'group');
},
getIndividuals: () => {
return get().contacts.filter(c => c.kind !== 'group');
},
getGroupMembers: (groupId) => {
const { contacts } = get();
const group = contacts.find(c => c.id === groupId);
if (!group?.members) return [];
const memberIds = Object.keys(group.members).filter(k => group.members![k]);
return contacts.filter(c => memberIds.includes(c.id) || memberIds.includes(c.uid || ''));
},
createGroup: async (client, name, memberIds) => {
const members: Record<string, boolean> = {};
memberIds.forEach(id => { members[id] = true; });
const groupData: Partial<ContactCard> = {
kind: 'group',
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
members,
};
if (client && get().supportsSync) {
const created = await client.createContact(groupData);
set((state) => ({ contacts: [...state.contacts, created] }));
} else {
const localGroup: ContactCard = {
id: `local-${crypto.randomUUID()}`,
addressBookIds: {},
...groupData,
} as ContactCard;
set((state) => ({ contacts: [...state.contacts, localGroup] }));
}
},
updateGroup: async (client, groupId, name) => {
const updates: Partial<ContactCard> = {
name: { components: [{ kind: 'given', value: name }], isOrdered: true },
};
if (client && get().supportsSync) {
await client.updateContact(groupId, updates);
}
set((state) => ({
contacts: state.contacts.map(c =>
c.id === groupId ? { ...c, ...updates } : c
),
}));
},
addMembersToGroup: async (client, groupId, memberIds) => {
const { contacts } = get();
const group = contacts.find(c => c.id === groupId);
if (!group) return;
const newMembers = { ...group.members };
memberIds.forEach(id => { newMembers[id] = true; });
const updates: Partial<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {
await client.updateContact(groupId, updates);
}
set((state) => ({
contacts: state.contacts.map(c =>
c.id === groupId ? { ...c, members: newMembers } : c
),
}));
},
removeMembersFromGroup: async (client, groupId, memberIds) => {
const { contacts } = get();
const group = contacts.find(c => c.id === groupId);
if (!group?.members) return;
const newMembers = { ...group.members };
memberIds.forEach(id => { delete newMembers[id]; });
const updates: Partial<ContactCard> = { members: newMembers };
if (client && get().supportsSync) {
await client.updateContact(groupId, updates);
}
set((state) => ({
contacts: state.contacts.map(c =>
c.id === groupId ? { ...c, members: newMembers } : c
),
}));
},
deleteGroup: async (client, groupId) => {
if (client && get().supportsSync) {
await client.deleteContact(groupId);
}
set((state) => ({
contacts: state.contacts.filter(c => c.id !== groupId),
selectedContactId: state.selectedContactId === groupId ? null : state.selectedContactId,
}));
},
toggleContactSelection: (id) => set((state) => {
const next = new Set(state.selectedContactIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return { selectedContactIds: next };
}),
selectAllContacts: (ids) => set({ selectedContactIds: new Set(ids) }),
clearSelection: () => set({ selectedContactIds: new Set<string>() }),
bulkDeleteContacts: async (client, ids) => {
set({ error: null });
const { supportsSync } = get();
const deletedIds = new Set(ids);
if (client && supportsSync) {
for (const id of ids) {
try {
await client.deleteContact(id);
} catch (error) {
console.error(`Failed to delete contact ${id}:`, error);
deletedIds.delete(id);
}
}
if (deletedIds.size < ids.length) {
set({ error: `Failed to delete ${ids.length - deletedIds.size} contact(s)` });
}
}
set((state) => ({
contacts: state.contacts.filter(c => !deletedIds.has(c.id)),
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
selectedContactIds: new Set<string>(),
}));
},
bulkAddToGroup: async (client, groupId, contactIds) => {
await get().addMembersToGroup(client, groupId, contactIds);
set({ selectedContactIds: new Set<string>() });
},
importContacts: async (client, contacts) => {
const { supportsSync } = get();
let imported = 0;
for (const contact of contacts) {
try {
if (client && supportsSync) {
const { id: _id, ...data } = contact;
const created = await client.createContact(data);
set((state) => ({ contacts: [...state.contacts, created] }));
} else {
const localContact: ContactCard = {
...contact,
id: `local-${crypto.randomUUID()}`,
};
set((state) => ({ contacts: [...state.contacts, localContact] }));
}
imported++;
} catch (error) {
console.error('Failed to import contact:', error);
}
}
return imported;
},
}),
{
name: 'contact-storage',
@@ -194,5 +409,5 @@ export const useContactStore = create<ContactStore>()(
)
);
export { getContactDisplayName, getContactPrimaryEmail };
export { getContactPrimaryEmail };
export type { ContactName };
+91 -8
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
interface EmailStore {
emails: Email[];
@@ -23,9 +24,14 @@ interface EmailStore {
newEmailNotification: Email | null; // New email notification for toast
// Thread expansion state
expandedThreadIds: Set<string>; // Which threads are expanded in the list
threadEmailsCache: Map<string, Email[]>; // Cache of fully fetched thread emails
isLoadingThread: string | null; // Thread ID currently being loaded
expandedThreadIds: Set<string>;
threadEmailsCache: Map<string, Email[]>;
isLoadingThread: string | null;
// Advanced search state
searchFilters: SearchFilters;
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => void;
@@ -51,6 +57,10 @@ interface EmailStore {
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
advancedSearch: (client: JMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
// Batch operations
@@ -106,6 +116,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
threadEmailsCache: new Map(),
isLoadingThread: null,
// Advanced search state
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
searchAbortController: null,
// Spam undo cache
spamUndoCache: new Map(),
@@ -222,15 +237,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
let result;
// Check if we're in search mode
if (searchQuery) {
// Load more search results (scoped to current mailbox)
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
if (searchQuery || hasFilters) {
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
} else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
}
} else {
// Load more from mailbox
// Find the mailbox to get its accountId (for shared folder support)
@@ -617,6 +638,68 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get();
if (searchAbortController) {
searchAbortController.abort();
}
const controller = new AbortController();
set({
isLoading: true,
error: null,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: controller,
});
try {
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return;
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
});
} catch (error) {
if (controller.signal.aborted) return;
set({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: null,
});
}
},
setSearchFilters: (filters) => {
set((state) => ({
searchFilters: { ...state.searchFilters, ...filters },
}));
},
clearSearchFilters: () => {
set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } });
},
toggleAdvancedSearch: () => {
set((state) => ({ isAdvancedSearchOpen: !state.isAdvancedSearchOpen }));
},
toggleStar: async (client, emailId) => {
try {
const email = get().emails.find(e => e.id === emailId);
+94
View File
@@ -0,0 +1,94 @@
import { create } from 'zustand';
import type { JMAPClient } from '@/lib/jmap/client';
interface VacationStore {
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
isLoading: boolean;
isSaving: boolean;
error: string | null;
isSupported: boolean;
fetchVacationResponse: (client: JMAPClient) => Promise<void>;
updateVacationResponse: (client: JMAPClient, updates: {
isEnabled?: boolean;
fromDate?: string | null;
toDate?: string | null;
subject?: string;
textBody?: string;
htmlBody?: string | null;
}) => Promise<void>;
setSupported: (supported: boolean) => void;
clearState: () => void;
}
export const useVacationStore = create<VacationStore>()((set) => ({
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
fetchVacationResponse: async (client) => {
set({ isLoading: true, error: null });
try {
const vacation = await client.getVacationResponse();
set({
isEnabled: vacation.isEnabled,
fromDate: vacation.fromDate,
toDate: vacation.toDate,
subject: vacation.subject || '',
textBody: vacation.textBody || '',
htmlBody: vacation.htmlBody,
isLoading: false,
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'fetch_error',
});
}
},
updateVacationResponse: async (client, updates) => {
set({ isSaving: true, error: null });
try {
await client.setVacationResponse(updates);
set((state) => ({
...state,
...updates,
isSaving: false,
}));
} catch (error) {
set({
isSaving: false,
error: error instanceof Error ? error.message : 'save_error',
});
throw error;
}
},
setSupported: (supported) => set({ isSupported: supported }),
clearState: () => set({
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
isLoading: false,
isSaving: false,
error: null,
isSupported: false,
}),
}));