feat: include directory users in recipient autocomplete

This commit is contained in:
Linus Rath
2026-06-14 14:52:13 +02:00
parent e8feb11983
commit b32e102ff9
3 changed files with 79 additions and 0 deletions
+24
View File
@@ -31,6 +31,8 @@ const defaultState = {
supportsSync: false,
selectedContactIds: new Set<string>(),
activeTab: 'all' as const,
directoryPrincipals: [],
directoryLoaded: false,
};
describe('contact-store', () => {
@@ -273,6 +275,28 @@ describe('contact-store', () => {
const results = useContactStore.getState().getAutocomplete('Multi');
expect(results).toHaveLength(2);
});
it('should augment results with directory principals', () => {
useContactStore.setState({
contacts: [],
directoryPrincipals: [{ name: 'Dana Director', email: 'dana@example.com' }],
});
const results = useContactStore.getState().getAutocomplete('dana');
expect(results).toHaveLength(1);
expect(results[0]).toEqual({ name: 'Dana Director', email: 'dana@example.com' });
});
it('should not duplicate a directory principal already matched as a contact', () => {
useContactStore.setState({
contacts: [
makeContact({ id: 'c1', name: { components: [{ kind: 'given', value: 'Jane' }], isOrdered: true }, emails: { e0: { address: 'jane@example.com' } } }),
],
directoryPrincipals: [{ name: 'Jane From Directory', email: 'JANE@example.com' }],
});
const results = useContactStore.getState().getAutocomplete('jane');
expect(results).toHaveLength(1);
expect(results[0].name).toBe('Jane');
});
});
describe('getGroups', () => {
+7
View File
@@ -239,6 +239,13 @@ function initializeFeatureStores(client: IJMAPClient): void {
useContactStore.getState().setSupportsSync(false);
}
// Directory (RFC 9670 principals) is independent of contacts support and only
// works when the server allows directory queries; populates recipient
// autocomplete with other users on the server.
if (client.supportsPrincipals()) {
useContactStore.getState().fetchDirectory(client).catch((err) => debug.error('Failed to fetch directory:', err));
}
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
+48
View File
@@ -159,7 +159,13 @@ interface ContactStore {
lastSelectedContactId: string | null;
activeTab: 'all' | 'groups';
// Directory (RFC 9670 principals) — other users/groups on the server, used to
// augment recipient autocomplete. Runtime only, not persisted.
directoryPrincipals: Array<{ name: string; email: string; description?: string }>;
directoryLoaded: boolean;
fetchContacts: (client: IJMAPClient) => Promise<void>;
fetchDirectory: (client: IJMAPClient) => Promise<void>;
fetchAddressBooks: (client: IJMAPClient) => Promise<void>;
fetchAllAccountsContacts: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
fetchAllAccountsAddressBooks: (accounts: ContactAccountClient[], activeLocalAccountId: string) => Promise<void>;
@@ -258,6 +264,8 @@ export const useContactStore = create<ContactStore>()(
selectedContactIds: new Set<string>(),
lastSelectedContactId: null,
activeTab: 'all' as const,
directoryPrincipals: [],
directoryLoaded: false,
fetchContacts: async (client) => {
set({ isLoading: true, error: null });
@@ -270,6 +278,28 @@ export const useContactStore = create<ContactStore>()(
}
},
fetchDirectory: async (client) => {
if (!client.supportsPrincipals()) return;
try {
const principals = await client.getPrincipals();
const entries: Array<{ name: string; email: string; description?: string }> = [];
for (const p of principals) {
// Stalwart reports a principal's account name in `email`; only those
// with an address are usable as recipients.
const email = p.email?.trim();
if (!email) continue;
entries.push({
name: p.name || email,
email,
description: p.description ?? undefined,
});
}
set({ directoryPrincipals: entries, directoryLoaded: true });
} catch (error) {
debug.error('Failed to fetch directory principals:', error);
}
},
fetchAddressBooks: async (client) => {
try {
const addressBooks = await client.getAllAddressBooks();
@@ -474,6 +504,8 @@ export const useContactStore = create<ContactStore>()(
error: null,
selectedContactIds: new Set<string>(),
activeTab: 'all',
directoryPrincipals: [],
directoryLoaded: false,
}),
getAutocomplete: (query) => {
@@ -516,6 +548,22 @@ export const useContactStore = create<ContactStore>()(
if (results.length >= 10) break;
}
// Augment with directory principals (other users on the server, RFC 9670).
// Contacts take precedence, so skip any address already suggested.
const { directoryPrincipals } = get();
if (directoryPrincipals.length > 0) {
const seen = new Set(results.map(r => r.email.toLowerCase()));
for (const p of directoryPrincipals) {
if (results.length >= 10) break;
const addr = p.email.toLowerCase();
if (seen.has(addr)) continue;
if (p.name.toLowerCase().includes(lower) || addr.includes(lower)) {
results.push({ name: p.name, email: p.email });
seen.add(addr);
}
}
}
return results;
},