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
+10 -1
View File
@@ -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();
+91 -5
View File
@@ -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({
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
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<Array<{ name: string; email: string }>>([]);
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<NodeJS.Timeout | null>(null);
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(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}
/>
</div>
@@ -2249,6 +2296,10 @@ export function EmailComposer({
autoSelectedIndex={autoSelectedIndex}
dropdownRef={bccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
canSearchServer={canSearchServer}
onServerSearch={handleServerSearch}
isSearchingServer={isSearchingServer}
serverSearchQuery={autoQuery}
onMoveChip={handleMoveChip}
/>
</div>
@@ -2670,7 +2721,10 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
results: Array<{ name: string; email: string }>;
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 (
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
{results.map((r, i) => (
@@ -2696,6 +2750,27 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
)}
</button>
))}
{onSearchServer && (
<button
type="button"
disabled={isSearchingServer}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
results.length > 0 && "border-t border-border"
)}
onMouseDown={(e) => {
e.preventDefault();
if (!isSearchingServer) onSearchServer();
}}
>
{isSearchingServer
? <Loader2 className="w-4 h-4 shrink-0 animate-spin" />
: <Search className="w-4 h-4 shrink-0" />}
<span className="truncate">
{isSearchingServer ? t('autocomplete_searching') : t('autocomplete_search_server')}
</span>
</button>
)}
</div>
);
});
@@ -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<HTMLDivElement | null>;
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 && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
)}
{activeAutoField === field && autocompleteResults.length > 0 && (
{activeAutoField === field &&
(autocompleteResults.length > 0 || (canSearchServer && serverSearchQuery.length > 0)) && (
<AutocompleteDropdown
ref={dropdownRef}
id={`autocomplete-${field}`}
results={autocompleteResults}
selectedIndex={autoSelectedIndex}
onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)}
onSearchServer={canSearchServer && serverSearchQuery.length > 0 ? onServerSearch : undefined}
isSearchingServer={isSearchingServer}
/>
)}
<ContextMenu
+17
View File
@@ -209,6 +209,23 @@ export class DemoJMAPClient implements IJMAPClient {
return { emails, hasMore: position + limit < total, total };
}
async searchSentRecipients(query: string, _sentMailboxId: string, _accountId?: string, _limit: number = 60): Promise<Array<{ name: string; email: string }>> {
const q = query.trim().toLowerCase();
if (!q) return [];
const byEmail = new Map<string, { name: string; email: string }>();
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<void> {
+7
View File
@@ -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<Array<{ name: string; email: string }>>;
// ── Email mutations ───────────────────────────────────────────
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
+47
View File
@@ -1887,6 +1887,53 @@ export class JMAPClient implements IJMAPClient {
}
}
async searchSentRecipients(query: string, sentMailboxId: string, accountId?: string, limit: number = 60): Promise<Array<{ name: string; email: string }>> {
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<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;
// 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<Thread | null> {
try {
const targetAccountId = accountId || this.accountId;
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "تأیید",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "確認",
+3 -1
View File
@@ -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": "확인",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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ź",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Подтвердить",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Підтвердити",
+3 -1
View File
@@ -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": "确认",
+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 });