Feature: recipient autocomplete from Sent, with on-demand server search

Compose recipient fields only suggested existing contacts and directory
users, so people you had emailed before but never saved as a contact
never came up. This adds an Outlook-Web-style suggestion flow.

On startup the Sent folder is read once (metadata only) to build a cache
of addresses you have written to; those are merged into the autocomplete
after contacts and directory principals, deduped, contacts winning.

When the recipient is not in the cache, the dropdown offers a "search the
server" row that queries the Sent folder on demand. That lookup fetches
only the to/cc fields (no subject, body or attachments) and returns the
matching addresses, deduped.

New strings are added to all 20 locales.
This commit is contained in:
dealerweb
2026-07-04 14:56:49 +02:00
committed by Linus Rath
parent 4d6b4b5b8e
commit e6aa79ed94
26 changed files with 302 additions and 26 deletions
+70
View File
@@ -155,6 +155,11 @@ interface ContactStore {
trustedSendersLoaded: boolean;
trustedSendersLoading: boolean;
// Recent recipients (from the Sent folder) for compose autocomplete - runtime only
recentRecipients: Array<{ name: string; email: string }>;
recentRecipientsLoaded: boolean;
sentMailboxId: string | null;
selectedContactIds: Set<string>;
lastSelectedContactId: string | null;
activeTab: 'all' | 'groups';
@@ -214,6 +219,11 @@ interface ContactStore {
addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise<void>;
removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise<void>;
isTrustedAddressBookSender: (email: string) => boolean;
// Recent recipients (compose autocomplete, derived from the Sent folder)
loadRecentRecipients: (client: IJMAPClient, sentMailboxId: string) => Promise<void>;
// On-demand "search the server" for recipients not in the recent cache
searchRecipients: (client: IJMAPClient, query: string) => Promise<Array<{ name: string; email: string }>>;
}
export const useContactStore = create<ContactStore>()(
@@ -262,6 +272,9 @@ export const useContactStore = create<ContactStore>()(
trustedSendersBookId: null,
trustedSendersLoaded: false,
trustedSendersLoading: false,
recentRecipients: [],
recentRecipientsLoaded: false,
sentMailboxId: null,
selectedContactIds: new Set<string>(),
lastSelectedContactId: null,
activeTab: 'all' as const,
@@ -576,6 +589,25 @@ export const useContactStore = create<ContactStore>()(
}
}
// Finally fold in recent recipients from the Sent folder (people you've
// written to - the OWA-style autocomplete cache). Contacts and directory
// principals take precedence, so skip any address already suggested (no
// duplicates). These carry the display name from the message, so match
// on name or address.
const { recentRecipients } = get();
if (recentRecipients.length > 0 && results.length < 10) {
const seenRecent = new Set(results.map(r => r.email.toLowerCase()));
for (const rec of recentRecipients) {
if (results.length >= 10) break;
const addr = rec.email.toLowerCase();
if (seenRecent.has(addr)) continue;
if (addr.includes(lower) || (rec.name && rec.name.toLowerCase().includes(lower))) {
results.push({ name: rec.name, email: rec.email });
seenRecent.add(addr);
}
}
}
return results;
},
@@ -959,6 +991,44 @@ export const useContactStore = create<ContactStore>()(
}
},
loadRecentRecipients: async (client, sentMailboxId) => {
if (sentMailboxId) set({ sentMailboxId });
if (get().recentRecipientsLoaded || !sentMailboxId) return;
try {
// Read the Sent folder and collect the people we've written to, so
// compose autocomplete can suggest them (OWA-style). getEmails sorts
// receivedAt desc, so keeping the first occurrence per address yields
// the most recent one plus its display name.
const { emails } = await client.getEmails(sentMailboxId, undefined, 300, 0);
const byEmail = new Map<string, { name: string; email: string }>();
for (const email of emails) {
for (const r of [...(email.to || []), ...(email.cc || [])]) {
if (!r.email) continue;
const key = r.email.toLowerCase().trim();
if (!key || byEmail.has(key)) continue;
byEmail.set(key, { name: (r.name || '').trim(), email: r.email });
}
}
set({ recentRecipients: Array.from(byEmail.values()), recentRecipientsLoaded: true });
debug.log('contacts', 'Loaded', byEmail.size, 'recent recipients from Sent');
} catch (error) {
debug.error('Failed to load recent recipients:', error);
set({ recentRecipientsLoaded: true });
}
},
searchRecipients: async (client, query) => {
const { sentMailboxId } = get();
const q = query.trim();
if (!sentMailboxId || q.length < 1) return [];
try {
return await client.searchSentRecipients(q, sentMailboxId);
} catch (error) {
debug.error('Recipient server search failed:', error);
return [];
}
},
loadTrustedSendersBook: async (client) => {
if (get().trustedSendersLoading) return;
set({ trustedSendersLoading: true });