From e6aa79ed945d6f6ab80faee3999ea9881061a8ae Mon Sep 17 00:00:00 2001 From: dealerweb Date: Thu, 2 Jul 2026 15:48:23 +0200 Subject: [PATCH] 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. --- app/(main)/[locale]/page.tsx | 11 +++- components/email/email-composer.tsx | 96 +++++++++++++++++++++++++++-- lib/demo/demo-client.ts | 17 +++++ lib/jmap/client-interface.ts | 7 +++ lib/jmap/client.ts | 47 ++++++++++++++ locales/cs/common.json | 4 +- locales/da/common.json | 4 +- locales/de/common.json | 4 +- locales/en/common.json | 4 +- locales/es/common.json | 4 +- locales/fa/common.json | 4 +- locales/fr/common.json | 4 +- locales/hu/common.json | 4 +- locales/it/common.json | 4 +- locales/ja/common.json | 4 +- locales/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ro/common.json | 4 +- locales/ru/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/zh/common.json | 4 +- stores/contact-store.ts | 70 +++++++++++++++++++++ 26 files changed, 302 insertions(+), 26 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 038e45f8..dd1d4fa9 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -121,7 +121,7 @@ export default function Home() { useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); - const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore(); + const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore(); const promptForRescheduleDelayedUntil = useCallback((): string | null => { const value = window.prompt(t('email_viewer.reschedule_prompt')); @@ -342,6 +342,15 @@ export default function Home() { refreshCurrentMailbox, } = useEmailStore(); + // Load recent recipients (from the Sent folder) for compose autocomplete. + // Runs once when the Sent mailbox is known; the store guards against reloads. + useEffect(() => { + const sent = mailboxes.find((m) => m.role === 'sent'); + if (client && sent) { + loadRecentRecipients(client, sent.originalId || sent.id); + } + }, [client, mailboxes, loadRecentRecipients]); + // Pro shell: populate per-account mailbox cache so the sidebar can render // every connected account Thunderbird-style. useProMultiAccountMailboxes(); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d445392c..e60cba9a 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -820,6 +820,10 @@ export function EmailComposer({ ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; const getAutocomplete = useContactStore((s) => s.getAutocomplete); + const searchRecipients = useContactStore((s) => s.searchRecipients); + // Whether a Sent mailbox is known so the on-demand server search is worth + // offering (falls back to hiding the "search the server" row otherwise). + const canSearchServer = useContactStore((s) => s.sentMailboxId != null); const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook); const addTrustedSender = useSettingsStore((s) => s.addTrustedSender); const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook); @@ -895,6 +899,10 @@ export function EmailComposer({ const [autocompleteResults, setAutocompleteResults] = useState>([]); const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null); const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1); + // Current trimmed query behind the open dropdown, plus the in-flight flag for + // the on-demand Sent-folder lookup ("search the server" row). + const [autoQuery, setAutoQuery] = useState(''); + const [isSearchingServer, setIsSearchingServer] = useState(false); const autocompleteTimeoutRef = useRef(null); const toInputRef = useRef(null); const ccInputRef = useRef(null); @@ -942,8 +950,10 @@ export function EmailComposer({ setAutocompleteResults([]); setActiveAutoField(null); setAutoSelectedIndex(-1); + setAutoQuery(''); return; } + setAutoQuery(query); autocompleteTimeoutRef.current = setTimeout(async () => { const localResults = getAutocomplete(query); @@ -951,10 +961,39 @@ export function EmailComposer({ const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email })); const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query }); setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email }))); - setActiveAutoField(merged.length > 0 ? field : null); + // Keep the dropdown open even without local hits when a server search is + // available, so the "search the server" row stays reachable (OWA-style). + setActiveAutoField(merged.length > 0 || canSearchServer ? field : null); setAutoSelectedIndex(-1); }, 200); - }, [getAutocomplete]); + }, [getAutocomplete, canSearchServer]); + + // On-demand: search the Sent folder server-side for recipients matching the + // current query and merge fresh hits into the open dropdown (deduped by email). + const handleServerSearch = useCallback(async () => { + const query = autoQuery.trim(); + if (!composerClient || !query || isSearchingServer) return; + setIsSearchingServer(true); + try { + const serverResults = await searchRecipients(composerClient, query); + setAutocompleteResults((prev) => { + const seen = new Set(prev.map((r) => r.email.toLowerCase())); + const merged = [...prev]; + for (const r of serverResults) { + const key = r.email.toLowerCase(); + if (!seen.has(key)) { + seen.add(key); + merged.push(r); + } + } + return merged; + }); + } catch { + // Best-effort: a failed lookup just leaves the local suggestions in place. + } finally { + setIsSearchingServer(false); + } + }, [autoQuery, composerClient, isSearchingServer, searchRecipients]); const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => { const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc; @@ -2147,6 +2186,10 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={toDropdownRef} onInsertAutocomplete={insertAutocomplete} + canSearchServer={canSearchServer} + onServerSearch={handleServerSearch} + isSearchingServer={isSearchingServer} + serverSearchQuery={autoQuery} validationError={validationErrors.to} validationMessage={t('validation.recipient_required')} onTab={focusSubject} @@ -2224,6 +2267,10 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={ccDropdownRef} onInsertAutocomplete={insertAutocomplete} + canSearchServer={canSearchServer} + onServerSearch={handleServerSearch} + isSearchingServer={isSearchingServer} + serverSearchQuery={autoQuery} onMoveChip={handleMoveChip} /> @@ -2249,6 +2296,10 @@ export function EmailComposer({ autoSelectedIndex={autoSelectedIndex} dropdownRef={bccDropdownRef} onInsertAutocomplete={insertAutocomplete} + canSearchServer={canSearchServer} + onServerSearch={handleServerSearch} + isSearchingServer={isSearchingServer} + serverSearchQuery={autoQuery} onMoveChip={handleMoveChip} /> @@ -2670,7 +2721,10 @@ const AutocompleteDropdown = React.forwardRef; selectedIndex: number; onSelect: (suggestion: { name: string; email: string }) => void; -}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) { + onSearchServer?: () => void; + isSearchingServer?: boolean; +}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) { + const t = useTranslations('email_composer'); return (
{results.map((r, i) => ( @@ -2696,6 +2750,27 @@ const AutocompleteDropdown = React.forwardRef ))} + {onSearchServer && ( + + )}
); }); @@ -2716,6 +2791,10 @@ function RecipientChipInput({ autoSelectedIndex, dropdownRef, onInsertAutocomplete, + canSearchServer, + onServerSearch, + isSearchingServer, + serverSearchQuery, validationError, validationMessage, onTab, @@ -2736,6 +2815,10 @@ function RecipientChipInput({ autoSelectedIndex: number; dropdownRef: React.RefObject; onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void; + canSearchServer: boolean; + onServerSearch: () => void; + isSearchingServer: boolean; + serverSearchQuery: string; validationError?: boolean; validationMessage?: string; onTab?: () => void; @@ -3037,13 +3120,16 @@ function RecipientChipInput({ {validationError && validationMessage && (

{validationMessage}

)} - {activeAutoField === field && autocompleteResults.length > 0 && ( + {activeAutoField === field && + (autocompleteResults.length > 0 || (canSearchServer && serverSearchQuery.length > 0)) && ( onInsertAutocomplete(suggestion, field)} + onSearchServer={canSearchServer && serverSearchQuery.length > 0 ? onServerSearch : undefined} + isSearchingServer={isSearchingServer} /> )} > { + const q = query.trim().toLowerCase(); + if (!q) return []; + const byEmail = new Map(); + for (const email of this.data.emails) { + for (const r of [...(email.to || []), ...(email.cc || [])]) { + if (!r.email) continue; + const key = r.email.toLowerCase(); + if (byEmail.has(key)) continue; + if (key.includes(q) || (r.name && r.name.toLowerCase().includes(q))) { + byEmail.set(key, { name: r.name || '', email: r.email }); + } + } + } + return Array.from(byEmail.values()); + } + // ── Email mutations ─────────────────────────────────────────── async markAsRead(emailId: string, read: boolean = true): Promise { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 5b332cfa..bb4ad31c 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -89,6 +89,13 @@ export interface IJMAPClient { limit?: number, position?: number, ): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; + /** + * Lean recipient search for compose autocomplete ("search the server" action): + * finds messages in `sentMailboxId` whose to/cc matches `query` and returns + * only the matching addresses (fetches just the `to`/`cc` properties - no + * bodies or attachments), deduped. + */ + searchSentRecipients(query: string, sentMailboxId: string, accountId?: string, limit?: number): Promise>; // ── Email mutations ─────────────────────────────────────────── markAsRead(emailId: string, read?: boolean, accountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 7c04ec45..d9773d71 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1887,6 +1887,53 @@ export class JMAPClient implements IJMAPClient { } } + async searchSentRecipients(query: string, sentMailboxId: string, accountId?: string, limit: number = 60): Promise> { + const q = query.trim(); + if (!q || !sentMailboxId) return []; + try { + const targetAccountId = accountId || this.accountId; + const response = await this.request([ + ["Email/query", { + accountId: targetAccountId, + filter: { + operator: "AND", + conditions: [ + { inMailbox: sentMailboxId }, + { operator: "OR", conditions: [{ to: q }, { cc: q }] }, + ], + }, + sort: [{ property: "receivedAt", isAscending: false }], + limit, + }, "0"], + // Fetch ONLY the recipient fields - no subject/preview/body/attachments. + ["Email/get", { + accountId: targetAccountId, + "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, + properties: ["to", "cc"], + }, "1"], + ]); + const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[]; + const lower = q.toLowerCase(); + const byEmail = new Map(); + 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; + // The query matched *some* recipient of the message; keep only the + // addresses that actually match, not every co-recipient. + if (key.includes(lower) || (r.name && r.name.toLowerCase().includes(lower))) { + byEmail.set(key, { name: (r.name || "").trim(), email: r.email }); + } + } + } + return Array.from(byEmail.values()); + } catch (error) { + console.error('Recipient search failed:', error); + return []; + } + } + async getThread(threadId: string, accountId?: string): Promise { try { const targetAccountId = accountId || this.accountId; diff --git a/locales/cs/common.json b/locales/cs/common.json index b5fc1070..32df1198 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Upravit e-mailovou adresu", "recipient_edit_name": "Upravit zobrazované jméno", "recipient_email_placeholder": "E-mailová adresa", - "recipient_name_placeholder": "Zobrazované jméno" + "recipient_name_placeholder": "Zobrazované jméno", + "autocomplete_search_server": "Hledat na serveru", + "autocomplete_searching": "Hledání..." }, "confirm_dialog": { "confirm": "Potvrdit", diff --git a/locales/da/common.json b/locales/da/common.json index 75d5ff6f..6c21af91 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Rediger e-mailadresse", "recipient_edit_name": "Rediger visningsnavn", "recipient_email_placeholder": "E-mailadresse", - "recipient_name_placeholder": "Visningsnavn" + "recipient_name_placeholder": "Visningsnavn", + "autocomplete_search_server": "Søg på serveren", + "autocomplete_searching": "Søger..." }, "confirm_dialog": { "confirm": "Bekræft", diff --git a/locales/de/common.json b/locales/de/common.json index 04dcfb71..4712df5c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "E-Mail-Adresse bearbeiten", "recipient_edit_name": "Anzeigenamen bearbeiten", "recipient_email_placeholder": "E-Mail-Adresse", - "recipient_name_placeholder": "Anzeigename" + "recipient_name_placeholder": "Anzeigename", + "autocomplete_search_server": "Auf dem Server suchen", + "autocomplete_searching": "Suche läuft..." }, "confirm_dialog": { "confirm": "Bestätigen", diff --git a/locales/en/common.json b/locales/en/common.json index d312fde4..aa9620e4 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Edit email address", "recipient_edit_name": "Edit display name", "recipient_email_placeholder": "Email address", - "recipient_name_placeholder": "Display name" + "recipient_name_placeholder": "Display name", + "autocomplete_search_server": "Search the server", + "autocomplete_searching": "Searching..." }, "confirm_dialog": { "confirm": "Confirm", diff --git a/locales/es/common.json b/locales/es/common.json index b70ba78b..d0977f0c 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Editar dirección de correo", "recipient_edit_name": "Editar nombre para mostrar", "recipient_email_placeholder": "Dirección de correo", - "recipient_name_placeholder": "Nombre para mostrar" + "recipient_name_placeholder": "Nombre para mostrar", + "autocomplete_search_server": "Buscar en el servidor", + "autocomplete_searching": "Buscando..." }, "confirm_dialog": { "confirm": "Confirmar", diff --git a/locales/fa/common.json b/locales/fa/common.json index 733991bb..602177d6 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "ویرایش آدرس ایمیل", "recipient_edit_name": "ویرایش نام نمایشی", "recipient_email_placeholder": "آدرس ایمیل", - "recipient_name_placeholder": "نام نمایشی" + "recipient_name_placeholder": "نام نمایشی", + "autocomplete_search_server": "جستجو در سرور", + "autocomplete_searching": "در حال جستجو..." }, "confirm_dialog": { "confirm": "تأیید", diff --git a/locales/fr/common.json b/locales/fr/common.json index 63627b8e..0ed9f37d 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Modifier l'adresse e-mail", "recipient_edit_name": "Modifier le nom d'affichage", "recipient_email_placeholder": "Adresse e-mail", - "recipient_name_placeholder": "Nom d'affichage" + "recipient_name_placeholder": "Nom d'affichage", + "autocomplete_search_server": "Rechercher sur le serveur", + "autocomplete_searching": "Recherche en cours..." }, "confirm_dialog": { "confirm": "Confirmer", diff --git a/locales/hu/common.json b/locales/hu/common.json index 76d5063e..90968c71 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "E-mail-cím szerkesztése", "recipient_edit_name": "Megjelenített név szerkesztése", "recipient_email_placeholder": "E-mail-cím", - "recipient_name_placeholder": "Megjelenített név" + "recipient_name_placeholder": "Megjelenített név", + "autocomplete_search_server": "Keresés a kiszolgálón", + "autocomplete_searching": "Keresés..." }, "confirm_dialog": { "confirm": "Megerősítés", diff --git a/locales/it/common.json b/locales/it/common.json index 6516a576..7d6a17da 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Modifica indirizzo email", "recipient_edit_name": "Modifica nome visualizzato", "recipient_email_placeholder": "Indirizzo email", - "recipient_name_placeholder": "Nome visualizzato" + "recipient_name_placeholder": "Nome visualizzato", + "autocomplete_search_server": "Cerca nel server", + "autocomplete_searching": "Ricerca in corso..." }, "confirm_dialog": { "confirm": "Conferma", diff --git a/locales/ja/common.json b/locales/ja/common.json index ea456771..3106458d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "メールアドレスを編集", "recipient_edit_name": "表示名を編集", "recipient_email_placeholder": "メールアドレス", - "recipient_name_placeholder": "表示名" + "recipient_name_placeholder": "表示名", + "autocomplete_search_server": "サーバーを検索", + "autocomplete_searching": "検索中..." }, "confirm_dialog": { "confirm": "確認", diff --git a/locales/ko/common.json b/locales/ko/common.json index 1bdf06be..9bef1adc 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "이메일 주소 편집", "recipient_edit_name": "표시 이름 편집", "recipient_email_placeholder": "이메일 주소", - "recipient_name_placeholder": "표시 이름" + "recipient_name_placeholder": "표시 이름", + "autocomplete_search_server": "서버에서 검색", + "autocomplete_searching": "검색 중..." }, "confirm_dialog": { "confirm": "확인", diff --git a/locales/lv/common.json b/locales/lv/common.json index 11be5d36..856ce746 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Rediģēt e-pasta adresi", "recipient_edit_name": "Rediģēt parādāmo vārdu", "recipient_email_placeholder": "E-pasta adrese", - "recipient_name_placeholder": "Parādāmais vārds" + "recipient_name_placeholder": "Parādāmais vārds", + "autocomplete_search_server": "Meklēt serverī", + "autocomplete_searching": "Meklē..." }, "confirm_dialog": { "confirm": "Apstiprināt", diff --git a/locales/nl/common.json b/locales/nl/common.json index 0bd16159..8e9569ce 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "E-mailadres bewerken", "recipient_edit_name": "Weergavenaam bewerken", "recipient_email_placeholder": "E-mailadres", - "recipient_name_placeholder": "Weergavenaam" + "recipient_name_placeholder": "Weergavenaam", + "autocomplete_search_server": "Op de server zoeken", + "autocomplete_searching": "Bezig met zoeken..." }, "confirm_dialog": { "confirm": "Bevestigen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 7d0cbd6e..746601c6 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Edytuj adres e-mail", "recipient_edit_name": "Edytuj wyświetlaną nazwę", "recipient_email_placeholder": "Adres e-mail", - "recipient_name_placeholder": "Wyświetlana nazwa" + "recipient_name_placeholder": "Wyświetlana nazwa", + "autocomplete_search_server": "Szukaj na serwerze", + "autocomplete_searching": "Wyszukiwanie..." }, "confirm_dialog": { "confirm": "Potwierdź", diff --git a/locales/pt/common.json b/locales/pt/common.json index 403cda31..26efb746 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Editar endereço de e-mail", "recipient_edit_name": "Editar nome de exibição", "recipient_email_placeholder": "Endereço de e-mail", - "recipient_name_placeholder": "Nome de exibição" + "recipient_name_placeholder": "Nome de exibição", + "autocomplete_search_server": "Pesquisar no servidor", + "autocomplete_searching": "Pesquisando..." }, "confirm_dialog": { "confirm": "Confirmar", diff --git a/locales/ro/common.json b/locales/ro/common.json index 7b7ea7f1..2a3af4cf 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Editați adresa de e-mail", "recipient_edit_name": "Editați numele afișat", "recipient_email_placeholder": "Adresă de e-mail", - "recipient_name_placeholder": "Numele afișat" + "recipient_name_placeholder": "Numele afișat", + "autocomplete_search_server": "Caută pe server", + "autocomplete_searching": "Se caută..." }, "confirm_dialog": { "confirm": "Confirmare", diff --git a/locales/ru/common.json b/locales/ru/common.json index a04adaed..6f9f6695 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Изменить адрес эл. почты", "recipient_edit_name": "Изменить отображаемое имя", "recipient_email_placeholder": "Адрес эл. почты", - "recipient_name_placeholder": "Отображаемое имя" + "recipient_name_placeholder": "Отображаемое имя", + "autocomplete_search_server": "Искать на сервере", + "autocomplete_searching": "Поиск..." }, "confirm_dialog": { "confirm": "Подтвердить", diff --git a/locales/tr/common.json b/locales/tr/common.json index 452aab29..e1ecea1d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "E-posta adresini düzenle", "recipient_edit_name": "Görünen adı düzenle", "recipient_email_placeholder": "E-posta adresi", - "recipient_name_placeholder": "Görünen ad" + "recipient_name_placeholder": "Görünen ad", + "autocomplete_search_server": "Sunucuda ara", + "autocomplete_searching": "Aranıyor..." }, "confirm_dialog": { "confirm": "Onayla", diff --git a/locales/uk/common.json b/locales/uk/common.json index 5967a6b5..0c2403bf 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "Змінити адресу ел. пошти", "recipient_edit_name": "Змінити відображуване ім'я", "recipient_email_placeholder": "Адреса ел. пошти", - "recipient_name_placeholder": "Відображуване ім'я" + "recipient_name_placeholder": "Відображуване ім'я", + "autocomplete_search_server": "Шукати на сервері", + "autocomplete_searching": "Пошук..." }, "confirm_dialog": { "confirm": "Підтвердити", diff --git a/locales/zh/common.json b/locales/zh/common.json index cfd9cfec..2d69e8f0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -677,7 +677,9 @@ "recipient_edit_email": "编辑电子邮件地址", "recipient_edit_name": "编辑显示名称", "recipient_email_placeholder": "电子邮件地址", - "recipient_name_placeholder": "显示名称" + "recipient_name_placeholder": "显示名称", + "autocomplete_search_server": "在服务器上搜索", + "autocomplete_searching": "搜索中..." }, "confirm_dialog": { "confirm": "确认", diff --git a/stores/contact-store.ts b/stores/contact-store.ts index f0a9cb6a..db65f4a2 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -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; lastSelectedContactId: string | null; activeTab: 'all' | 'groups'; @@ -214,6 +219,11 @@ interface ContactStore { addToTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; removeFromTrustedSendersBook: (client: IJMAPClient, email: string) => Promise; isTrustedAddressBookSender: (email: string) => boolean; + + // Recent recipients (compose autocomplete, derived from the Sent folder) + loadRecentRecipients: (client: IJMAPClient, sentMailboxId: string) => Promise; + // On-demand "search the server" for recipients not in the recent cache + searchRecipients: (client: IJMAPClient, query: string) => Promise>; } export const useContactStore = create()( @@ -262,6 +272,9 @@ export const useContactStore = create()( trustedSendersBookId: null, trustedSendersLoaded: false, trustedSendersLoading: false, + recentRecipients: [], + recentRecipientsLoaded: false, + sentMailboxId: null, selectedContactIds: new Set(), lastSelectedContactId: null, activeTab: 'all' as const, @@ -576,6 +589,25 @@ export const useContactStore = create()( } } + // 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()( } }, + 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(); + 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 });