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:
@@ -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();
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "تأیید",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "確認",
|
||||
|
||||
@@ -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": "확인",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ź",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Подтвердить",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Підтвердити",
|
||||
|
||||
@@ -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": "确认",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user