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