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:
+217
-2
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user