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
+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;