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
+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 };