import type { Email } from "@/lib/email-data" import type { FullContact } from "@/lib/contacts/types" import type { SearchParams } from "./search-params" // --------------------------------------------------------------------------- // Suggestion types // --------------------------------------------------------------------------- export type ContactSuggestion = { kind: "contact" contact: FullContact email: string displayName: string /** Score 0-1 (higher = better match). */ score: number } export type EmailSuggestion = { kind: "email" email: string displayName: string score: number } export type SearchSuggestion = ContactSuggestion | EmailSuggestion // --------------------------------------------------------------------------- // Prefix matching helpers // --------------------------------------------------------------------------- function normalize(s: string): string { return s .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") } function prefixScore(haystack: string, needle: string): number { const h = normalize(haystack) const n = normalize(needle) if (!n) return 0 if (h === n) return 1 if (h.startsWith(n)) return 0.9 + 0.1 * (n.length / h.length) const idx = h.indexOf(n) if (idx > 0) return 0.5 + 0.4 * (n.length / h.length) return 0 } // --------------------------------------------------------------------------- // matchContacts // --------------------------------------------------------------------------- export function matchContacts( query: string, contacts: FullContact[], limit = 5 ): ContactSuggestion[] { if (!query.trim()) return [] const results: ContactSuggestion[] = [] for (const c of contacts) { const fullName = `${c.firstName} ${c.lastName}`.trim() let bestScore = 0 let bestEmail = c.emails[0]?.value ?? "" bestScore = Math.max(bestScore, prefixScore(fullName, query)) bestScore = Math.max(bestScore, prefixScore(c.firstName, query)) bestScore = Math.max(bestScore, prefixScore(c.lastName, query)) for (const e of c.emails) { const s = prefixScore(e.value, query) if (s > bestScore) { bestScore = s bestEmail = e.value } } if (bestScore > 0) { results.push({ kind: "contact", contact: c, email: bestEmail, displayName: fullName, score: bestScore, }) } } results.sort((a, b) => b.score - a.score) return results.slice(0, limit) } // --------------------------------------------------------------------------- // matchEmails (unique sender emails from email data) // --------------------------------------------------------------------------- export function matchEmails( query: string, emails: Email[], limit = 5 ): EmailSuggestion[] { if (!query.trim()) return [] const seen = new Map() for (const e of emails) { if (!e.senderEmail) continue const addr = e.senderEmail.toLowerCase() const name = e.sender const s1 = prefixScore(addr, query) const s2 = prefixScore(name, query) const s = Math.max(s1, s2) if (s > 0) { const existing = seen.get(addr) if (!existing || s > existing.score) { seen.set(addr, { name, score: s }) } } } const results: EmailSuggestion[] = [] for (const [addr, { name, score }] of seen) { results.push({ kind: "email", email: addr, displayName: name, score }) } results.sort((a, b) => b.score - a.score) return results.slice(0, limit) } // --------------------------------------------------------------------------- // Ghost text (best completion for the input) // --------------------------------------------------------------------------- export function bestCompletion( query: string, suggestions: SearchSuggestion[] ): string { if (!query.trim() || suggestions.length === 0) return "" const top = suggestions[0]! const q = normalize(query) const candidates = top.kind === "contact" ? [top.email, top.displayName] : [top.email, top.displayName] for (const c of candidates) { const cn = normalize(c) if (cn.startsWith(q)) { return c.slice(query.length) } } return "" } // --------------------------------------------------------------------------- // filterEmailsBySearchParams // --------------------------------------------------------------------------- function withinToMs(within: string): number { const map: Record = { "1d": 86_400_000, "3d": 3 * 86_400_000, "1w": 7 * 86_400_000, "2w": 14 * 86_400_000, "1m": 30 * 86_400_000, "2m": 60 * 86_400_000, "6m": 180 * 86_400_000, "1y": 365 * 86_400_000, } return map[within] ?? 0 } function textMatchesEmail(email: Email, text: string): boolean { const t = normalize(text) if (!t) return true const fields = [ email.subject, email.preview, email.sender, email.senderEmail ?? "", email.body ?? "", ] for (const f of fields) { if (normalize(f).includes(t)) return true } if (email.conversation) { for (const msg of email.conversation) { if (normalize(msg.sender).includes(t)) return true if (normalize(msg.senderEmail).includes(t)) return true if (normalize(msg.preview).includes(t)) return true if (normalize(msg.body).includes(t)) return true } } return false } export function filterEmailsBySearchParams( emails: Email[], params: SearchParams, opts?: { starredIds?: string[] importantIds?: string[] } ): Email[] { const now = Date.now() return emails.filter((email) => { if (params.in === "all" && email.spam) return false if (params.in === "inbox" && !email.labels?.includes("inbox")) return false if (params.in === "sent" && !email.labels?.includes("sent")) return false if (params.in === "drafts" && !email.labels?.includes("drafts")) return false if (params.in === "spam" && !email.spam) return false if (params.in === "trash" && !email.deleted) return false if (params.in === "starred") { const isStarred = email.starred || (opts?.starredIds?.includes(email.id) ?? false) if (!isStarred) return false } if (params.q && !textMatchesEmail(email, params.q)) return false if (params.hasWords && !textMatchesEmail(email, params.hasWords)) return false if (params.doesNotHave) { if (textMatchesEmail(email, params.doesNotHave)) return false } if (params.from) { const f = normalize(params.from) const senderMatch = normalize(email.sender).includes(f) || normalize(email.senderEmail ?? "").includes(f) if (!senderMatch) return false } if (params.to) { const t = normalize(params.to) let found = false if (email.conversation) { for (const msg of email.conversation) { if ( normalize(msg.sender).includes(t) || normalize(msg.senderEmail).includes(t) ) found = true } } if ( normalize(email.sender).includes(t) || normalize(email.senderEmail ?? "").includes(t) ) found = true if (!found) return false } if (params.subject) { if (!normalize(email.subject).includes(normalize(params.subject))) return false } if (params.has.includes("attachment") && !email.hasAttachment) return false if (params.within) { const ms = withinToMs(params.within) if (ms > 0) { const emailDate = new Date(email.date).getTime() if (now - emailDate > ms) return false } } if (params.after) { const afterDate = new Date(params.after).getTime() if (Number.isFinite(afterDate) && new Date(email.date).getTime() < afterDate) return false } if (params.before) { const beforeDate = new Date(params.before).getTime() if (Number.isFinite(beforeDate) && new Date(email.date).getTime() > beforeDate) return false } if (params.size) { const sizeNum = parseFloat(params.size) if (Number.isFinite(sizeNum) && sizeNum > 0) { const multiplier = params.sizeUnit === "Mo" ? 1_048_576 : 1024 const thresholdBytes = sizeNum * multiplier const totalSize = email.attachments?.reduce((a, att) => a + (att.sizeBytes ?? 0), 0) ?? 0 if (params.sizeOp === "gt" && totalSize <= thresholdBytes) return false if (params.sizeOp === "lt" && totalSize >= thresholdBytes) return false } } return true }) }