From 1baa6ff813a125e20a287d9cfc29b66b6778936c Mon Sep 17 00:00:00 2001 From: pcarter Date: Sun, 26 Apr 2026 11:46:29 -0700 Subject: [PATCH] contacts autocomplete --- src/app/api/contacts/route.ts | 10 ++- src/app/compose/actions.ts | 6 +- src/lib/__tests__/jmap.test.ts | 100 +++++++++++++++++++++- src/lib/jmap.ts | 151 ++++++++++++++++++++++++++++++--- 4 files changed, 246 insertions(+), 21 deletions(-) diff --git a/src/app/api/contacts/route.ts b/src/app/api/contacts/route.ts index 49c5eec..04e5721 100644 --- a/src/app/api/contacts/route.ts +++ b/src/app/api/contacts/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth"; -import { getSession, getAccountId, searchContacts } from "@/lib/jmap"; +import { getAccountId, getContactsAccountId, getSession, searchRecipientSuggestions } from "@/lib/jmap"; export async function GET(req: NextRequest) { const sessionData = await auth(); @@ -13,8 +13,12 @@ export async function GET(req: NextRequest) { try { const session = await getSession(); - const accountId = getAccountId(session); - const results = await searchContacts(session.apiUrl, accountId, q.trim()); + const results = await searchRecipientSuggestions( + session.apiUrl, + getContactsAccountId(session), + getAccountId(session), + q.trim() + ); return NextResponse.json(results); } catch { return NextResponse.json([]); diff --git a/src/app/compose/actions.ts b/src/app/compose/actions.ts index 977edde..eab556e 100644 --- a/src/app/compose/actions.ts +++ b/src/app/compose/actions.ts @@ -47,9 +47,9 @@ export async function saveDraftAction( const identity = identities.find((candidate) => candidate.email === input.fromEmail); if (!identity) throw new Error("Invalid from address"); - const toAddrs = parseAddresses(splitRaw(input.to)); - const ccAddrs = parseAddresses(splitRaw(input.cc)); - const bccAddrs = parseAddresses(splitRaw(input.bcc)); + const toAddrs = parseAddresses(splitRaw(input.to), { strict: false }); + const ccAddrs = parseAddresses(splitRaw(input.cc), { strict: false }); + const bccAddrs = parseAddresses(splitRaw(input.bcc), { strict: false }); const draftId = await saveDraft( session.apiUrl, diff --git a/src/lib/__tests__/jmap.test.ts b/src/lib/__tests__/jmap.test.ts index 0bbf798..aaccadc 100644 --- a/src/lib/__tests__/jmap.test.ts +++ b/src/lib/__tests__/jmap.test.ts @@ -2,7 +2,7 @@ process.env.FASTMAIL_API_TOKEN = "test-token"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { deleteDraft, getAccountId, getUnreadInboxTotal, listInboxEmails, loadMoreEmailsFiltered, moveEmailsToMailbox, parseAddresses, sendEmail, setKeywordsOnMany } from "../jmap"; +import { deleteDraft, getAccountId, getContactsAccountId, getUnreadInboxTotal, listInboxEmails, loadMoreEmailsFiltered, moveEmailsToMailbox, parseAddresses, searchContacts, searchRecipientSuggestions, sendEmail, setKeywordsOnMany } from "../jmap"; const MAIL_CAP = "urn:ietf:params:jmap:mail"; @@ -64,6 +64,25 @@ describe("getAccountId", () => { }); }); +describe("getContactsAccountId", () => { + it("returns the contacts primary account when available", () => { + const session = { + primaryAccounts: { + [MAIL_CAP]: "mail-123", + "urn:ietf:params:jmap:contacts": "contacts-456", + }, + } as any; + assert.equal(getContactsAccountId(session), "contacts-456"); + }); + + it("falls back to the mail account when contacts is unavailable", () => { + const session = { + primaryAccounts: { [MAIL_CAP]: "mail-123" }, + } as any; + assert.equal(getContactsAccountId(session), "mail-123"); + }); +}); + describe("parseAddresses", () => { it("parses display names and bare addresses", () => { assert.deepEqual(parseAddresses(["Alice Example ", "bob@example.com"]), [ @@ -75,6 +94,85 @@ describe("parseAddresses", () => { it("throws on invalid email addresses", () => { assert.throws(() => parseAddresses(["definitely not an email"]), /Invalid email address/); }); + + it("allows incomplete addresses in non-strict mode", () => { + assert.deepEqual(parseAddresses(["alyo"], { strict: false }), [ + { name: null, email: "alyo" }, + ]); + }); +}); + +describe("searchContacts", () => { + it("uses ContactCard and returns flattened suggestions", async () => { + capturedBodies = []; + mockResponses = [{ + methodResponses: [ + ["ContactCard/query", { ids: ["c1"] }, "q"], + ["ContactCard/get", { + list: [ + { + name: { full: "Alice Example" }, + emails: { + e1: { address: "alice@example.com" }, + e2: { address: "a@example.com" }, + }, + }, + ], + }, "g"], + ], + }]; + + const results = await searchContacts("https://api.example.com/jmap", "acct1", "ali"); + + assert.deepEqual((capturedBodies[0] as any).using, [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:contacts", + ]); + assert.equal((capturedBodies[0] as any).methodCalls[0][0], "ContactCard/query"); + assert.equal((capturedBodies[0] as any).methodCalls[1][0], "ContactCard/get"); + assert.deepEqual(results, [ + { name: "Alice Example", email: "alice@example.com" }, + { name: "Alice Example", email: "a@example.com" }, + ]); + }); +}); + +describe("searchRecipientSuggestions", () => { + it("falls back to recent correspondents when contacts are empty", async () => { + capturedBodies = []; + mockResponses = [ + { + methodResponses: [ + ["ContactCard/query", { ids: [] }, "q"], + ["ContactCard/get", { list: [] }, "g"], + ], + }, + makeJmapResponse([ + ["Email/query", { ids: ["e1"] }, "q"], + ["Email/get", { + list: [ + { + from: [{ name: "Alyona", email: "alyona@example.com" }], + to: null, + cc: null, + replyTo: null, + }, + ], + }, "g"], + ]), + ]; + + const results = await searchRecipientSuggestions( + "https://api.example.com/jmap", + "contacts1", + "mail1", + "alyo" + ); + + assert.deepEqual(results, [ + { name: "Alyona", email: "alyona@example.com" }, + ]); + }); }); // --------------------------------------------------------------------------- diff --git a/src/lib/jmap.ts b/src/lib/jmap.ts index a9b6090..a7cc343 100644 --- a/src/lib/jmap.ts +++ b/src/lib/jmap.ts @@ -1,7 +1,8 @@ -import { Email, Identity, JMAPSession, Mailbox } from "./types"; +import { Email, EmailAddress, Identity, JMAPSession, Mailbox } from "./types"; import { log } from "./logger"; const SESSION_URL = "https://api.fastmail.com/jmap/session"; +const CONTACTS_CAPABILITY = "urn:ietf:params:jmap:contacts"; const JMAP_USING = [ "urn:ietf:params:jmap:core", @@ -65,6 +66,10 @@ export function getAccountId(session: JMAPSession): string { return id; } +export function getContactsAccountId(session: JMAPSession): string { + return session.primaryAccounts[CONTACTS_CAPABILITY] ?? getAccountId(session); +} + export async function getMailboxes( apiUrl: string, accountId: string @@ -648,10 +653,14 @@ export interface InlineImage { type: string; } -export function parseAddresses(addrs: string[]): { name: string | null; email: string }[] { +export function parseAddresses( + addrs: string[], + options?: ParseAddressOptions +): { name: string | null; email: string }[] { + const strict = options?.strict ?? true; return addrs.map((addr) => { const validateEmail = (value: string) => { - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + if (strict && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { throw new Error(`Invalid email address: ${value}`); } return value; @@ -896,6 +905,29 @@ export interface ContactSuggestion { email: string; } +interface ParseAddressOptions { + strict?: boolean; +} + +function normalizeSuggestionName(name: string | null | undefined, email: string): string { + return name?.trim() || email; +} + +function dedupeSuggestions(suggestions: ContactSuggestion[], limit = 10): ContactSuggestion[] { + const deduped: ContactSuggestion[] = []; + const seen = new Set(); + + for (const suggestion of suggestions) { + const key = suggestion.email.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(suggestion); + if (deduped.length >= limit) break; + } + + return deduped; +} + export async function searchContacts( apiUrl: string, accountId: string, @@ -910,16 +942,16 @@ export async function searchContacts( body: JSON.stringify({ using: [ "urn:ietf:params:jmap:core", - "https://cyrusimap.org/ns/jmap/contacts", + CONTACTS_CAPABILITY, ], methodCalls: [ - ["Contact/query", { accountId, filter: { text: query }, limit: 10 }, "q"], + ["ContactCard/query", { accountId, filter: { text: query }, limit: 10 }, "q"], [ - "Contact/get", + "ContactCard/get", { accountId, - "#ids": { resultOf: "q", name: "Contact/query", path: "/ids" }, - properties: ["firstName", "lastName", "emails"], + "#ids": { resultOf: "q", name: "ContactCard/query", path: "/ids" }, + properties: ["name", "emails"], }, "g", ], @@ -936,17 +968,108 @@ export async function searchContacts( } const data = await res.json(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const contacts: any[] = data.methodResponses?.[1]?.[1]?.list ?? []; + const contacts = ((data.methodResponses?.[1]?.[1] as { + list?: Array<{ + name?: { full?: string | null } | null; + emails?: Record | null; + }>; + } | undefined)?.list) ?? []; const results: ContactSuggestion[] = []; for (const c of contacts) { - const name = [c.firstName, c.lastName].filter(Boolean).join(" ").trim(); - for (const entry of c.emails ?? []) { - if (entry.value) results.push({ name: name || entry.value, email: entry.value }); + const name = c.name?.full?.trim(); + for (const entry of Object.values(c.emails ?? {})) { + const email = entry.address?.trim(); + if (email) { + results.push({ name: normalizeSuggestionName(name, email), email }); + } } } log.info({ query_len: query.length, results: results.length, duration_ms }, "jmap.contacts"); - return results; + return dedupeSuggestions(results); +} + +async function searchRecentCorrespondents( + apiUrl: string, + accountId: string, + query: string, + limit = 10 +): Promise { + const t = Date.now(); + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return []; + + const data = await jmapCall(apiUrl, [ + [ + "Email/query", + { + accountId, + filter: { + operator: "OR", + conditions: [ + { from: query }, + { to: query }, + { cc: query }, + { text: query }, + ], + }, + sort: [{ property: "receivedAt", isAscending: false }], + limit: 20, + }, + "q", + ], + [ + "Email/get", + { + accountId, + "#ids": { resultOf: "q", name: "Email/query", path: "/ids" }, + properties: ["from", "to", "cc", "replyTo", "receivedAt"], + }, + "g", + ], + ]); + + const emails = (((data.methodResponses[1]?.[1]) as { + list?: Array<{ + from?: EmailAddress[] | null; + to?: EmailAddress[] | null; + cc?: EmailAddress[] | null; + replyTo?: EmailAddress[] | null; + }>; + }).list) ?? []; + + const suggestions: ContactSuggestion[] = []; + for (const email of emails) { + for (const addresses of [email.from, email.to, email.cc, email.replyTo]) { + for (const address of addresses ?? []) { + const emailAddress = address.email?.trim(); + if (!emailAddress) continue; + const haystack = `${address.name ?? ""} ${emailAddress}`.toLowerCase(); + if (!haystack.includes(normalizedQuery)) continue; + suggestions.push({ + name: normalizeSuggestionName(address.name, emailAddress), + email: emailAddress, + }); + } + } + } + + const deduped = dedupeSuggestions(suggestions, limit); + log.info({ query_len: query.length, results: deduped.length, duration_ms: Date.now() - t }, "jmap.correspondents"); + return deduped; +} + +export async function searchRecipientSuggestions( + apiUrl: string, + contactsAccountId: string, + mailAccountId: string, + query: string, + limit = 10 +): Promise { + const contacts = await searchContacts(apiUrl, contactsAccountId, query); + if (contacts.length >= limit) return contacts.slice(0, limit); + + const correspondents = await searchRecentCorrespondents(apiUrl, mailAccountId, query, limit); + return dedupeSuggestions([...contacts, ...correspondents], limit); } -- 2.51.2