diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index e5d407f8..52d314f5 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -53,6 +53,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, archiveThread, trashThread, markThreadRead, getAccountEmail, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js'; +import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -444,6 +446,13 @@ export function setupIpcHandlers() { // Forward knowledge commit events to renderer for panel refresh versionHistory.onCommit(() => emitKnowledgeCommitEvent()); + // Pre-warm the Gmail contact indices so the first compose-box keystroke is instant. + // - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage). + // - warmSentContacts(): kicks off a background Gmail API sync of the SENT label + // for full historical coverage of people you've actually emailed. + warmContactIndex(); + warmSentContacts(); + registerIpcHandlers({ 'app:getVersions': async () => { // args is null for this channel (no request payload) @@ -521,6 +530,22 @@ export function setupIpcHandlers() { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; }, + 'gmail:searchContacts': async (_event, args) => { + const query = args?.query ?? ''; + const limit = args?.limit; + const excludeEmails = args?.excludeEmails; + + // Primary source: people you've actually sent mail to (Gmail SENT label, + // cached + refreshed via the Gmail API). Fallback: local-snapshot index + // — used only when the SENT index hasn't been populated yet (very first + // launch, before the background sync finishes). + const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []); + if (sent.length > 0) { + return { contacts: sent }; + } + const fallback = await searchGmailContacts(query, { limit, excludeEmails }); + return { contacts: fallback }; + }, 'mcp:listTools': async (_event, args) => { return mcpCore.listTools(args.serverName, args.cursor); }, diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 86c6535d..02cfd7bd 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -800,6 +800,108 @@ gap: 4px; flex: 1; min-width: 0; + position: relative; +} + +.gmail-recipient-suggestions { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 30; + margin: 0; + padding: 6px; + list-style: none; + width: max-content; + min-width: 280px; + max-width: min(440px, 100%); + background: var(--gm-bg-elevated, #1e1e1e); + border: 1px solid var(--gm-border); + border-radius: 10px; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.18), + 0 12px 32px rgba(0, 0, 0, 0.36); + max-height: 296px; + overflow-y: auto; + overscroll-behavior: contain; + transform-origin: top left; + animation: gmail-recipient-suggestions-in 110ms cubic-bezier(0.2, 0.7, 0.2, 1); +} + +@keyframes gmail-recipient-suggestions-in { + from { + opacity: 0; + transform: translateY(-2px) scale(0.985); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.gmail-recipient-suggestion { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 6px; + font-size: 13px; + color: var(--gm-text); + cursor: pointer; + transition: background-color 80ms linear; +} + +.gmail-recipient-suggestion:hover { + background: var(--gm-bg-pill-hover); +} + +.gmail-recipient-suggestion.is-active { + background: rgba(99, 142, 255, 0.18); +} + +.gmail-recipient-suggestion-avatar { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 50%; + color: #fff; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.2px; + text-transform: uppercase; +} + +.gmail-recipient-suggestion-text { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + line-height: 1.25; +} + +.gmail-recipient-suggestion-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; +} + +.gmail-recipient-suggestion-email { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11.5px; + color: var(--gm-text-muted); + margin-top: 1px; +} + +.gmail-recipient-suggestion-match { + background: transparent; + color: inherit; + font-weight: 700; + padding: 0; } .gmail-recipient-chip { diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index dea0561e..86f79fa7 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -612,6 +612,43 @@ function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () ) } +type ContactSuggestion = { + name: string + email: string +} + +function formatContactToken(c: ContactSuggestion): string { + return c.name ? `${c.name} <${c.email}>` : c.email +} + +// Stable hue per email so the avatar circle keeps a consistent color. +function contactHue(email: string): number { + let h = 0 + for (let i = 0; i < email.length; i++) h = (h * 31 + email.charCodeAt(i)) >>> 0 + return h % 360 +} + +function contactInitial(c: ContactSuggestion): string { + const src = (c.name || c.email).trim() + return (src[0] || '?').toUpperCase() +} + +// Renders a string with the matched substring wrapped in . +function HighlightedText({ text, query }: { text: string; query: string }) { + if (!query) return <>{text} + const lower = text.toLowerCase() + const q = query.toLowerCase() + const idx = lower.indexOf(q) + if (idx < 0) return <>{text} + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + + ) +} + function RecipientField({ label, value, @@ -626,34 +663,123 @@ function RecipientField({ trailing?: React.ReactNode }) { const [draft, setDraft] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [activeIndex, setActiveIndex] = useState(0) + const [isFocused, setIsFocused] = useState(false) + const [queryShown, setQueryShown] = useState('') const inputRef = useRef(null) + const fieldRef = useRef(null) + const listRef = useRef(null) + const queryTokenRef = useRef(0) useEffect(() => { if (autoFocus) inputRef.current?.focus() }, [autoFocus]) + const excludeEmails = useMemo( + () => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean), + [value], + ) + + // Debounced contact search — only runs when the user has actually typed + // something. An empty draft (including the post-pick reset) closes the menu. + useEffect(() => { + const trimmed = draft.trim() + if (!isFocused || !trimmed) { + queryTokenRef.current++ + setSuggestions([]) + return + } + const token = ++queryTokenRef.current + const timer = window.setTimeout(async () => { + try { + const result = (await window.ipc.invoke('gmail:searchContacts', { + query: draft, + limit: 8, + excludeEmails, + })) as { contacts?: ContactSuggestion[] } | undefined + if (token !== queryTokenRef.current) return + setSuggestions(result?.contacts ?? []) + setQueryShown(trimmed) + setActiveIndex(0) + } catch { + if (token !== queryTokenRef.current) return + setSuggestions([]) + } + }, 60) + return () => window.clearTimeout(timer) + }, [draft, isFocused, excludeEmails]) + + // Keep the active row scrolled into view during keyboard navigation. + useEffect(() => { + const list = listRef.current + if (!list) return + const node = list.children[activeIndex] as HTMLElement | undefined + node?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, suggestions]) + const commit = (raw: string) => { const additions = splitAddresses(raw) if (additions.length === 0) return onChange(dedupeRecipients([...value, ...additions], new Set())) setDraft('') + setSuggestions([]) + } + + const pickSuggestion = (c: ContactSuggestion) => { + commit(formatContactToken(c)) + // Keep focus in the input so the user can keep typing more recipients. + inputRef.current?.focus() } const onKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter' || event.key === ',' || event.key === ';' || (event.key === 'Tab' && draft.trim())) { + const hasSuggestions = suggestions.length > 0 + if (event.key === 'ArrowDown' && hasSuggestions) { + event.preventDefault() + setActiveIndex((i) => (i + 1) % suggestions.length) + return + } + if (event.key === 'ArrowUp' && hasSuggestions) { + event.preventDefault() + setActiveIndex((i) => (i - 1 + suggestions.length) % suggestions.length) + return + } + if (event.key === 'Escape' && hasSuggestions) { + event.preventDefault() + setSuggestions([]) + return + } + if (event.key === 'Enter' || (event.key === 'Tab' && hasSuggestions)) { + // Prefer the highlighted suggestion when one is present. + if (hasSuggestions) { + event.preventDefault() + pickSuggestion(suggestions[activeIndex]) + return + } + if (event.key === 'Enter' && draft.trim()) { + event.preventDefault() + commit(draft) + return + } + } + if (event.key === ',' || event.key === ';') { if (draft.trim()) { event.preventDefault() commit(draft) } - } else if (event.key === 'Backspace' && !draft && value.length > 0) { + return + } + if (event.key === 'Backspace' && !draft && value.length > 0) { onChange(value.slice(0, -1)) } } + const showSuggestions = isFocused && suggestions.length > 0 + return (
{label} -
+
{value.map((token, index) => ( {recipientLabel(token)} @@ -674,7 +800,16 @@ function RecipientField({ value={draft} onChange={(event) => setDraft(event.target.value)} onKeyDown={onKeyDown} - onBlur={() => { if (draft.trim()) commit(draft) }} + onFocus={() => setIsFocused(true)} + onBlur={() => { + // Defer so a mousedown on a suggestion can pick it before the menu closes. + window.setTimeout(() => { + setIsFocused(false) + if (inputRef.current && draft.trim() && document.activeElement !== inputRef.current) { + commit(draft) + } + }, 80) + }} onPaste={(event) => { const text = event.clipboardData.getData('text') if (text && /[,;\n]/.test(text)) { @@ -683,6 +818,45 @@ function RecipientField({ } }} /> + {showSuggestions && ( +
    + {suggestions.map((c, idx) => { + const hue = contactHue(c.email) + return ( +
  • { + // Prevent input blur before click fires. + event.preventDefault() + pickSuggestion(c) + }} + onMouseEnter={() => setActiveIndex(idx)} + > + + + + + + {c.name && ( + + + + )} + +
  • + ) + })} +
+ )}
{trailing &&
{trailing}
}
diff --git a/apps/x/packages/core/src/knowledge/gmail_contacts.ts b/apps/x/packages/core/src/knowledge/gmail_contacts.ts new file mode 100644 index 00000000..30842d4b --- /dev/null +++ b/apps/x/packages/core/src/knowledge/gmail_contacts.ts @@ -0,0 +1,348 @@ +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import type { GmailThreadSnapshot } from './sync_gmail.js'; +import { getAccountEmail } from './sync_gmail.js'; + +const CACHE_DIR = path.join(WorkDir, 'inbox_lists'); +const INDEX_TTL_MS = 5 * 60 * 1000; +const RECENCY_HALFLIFE_DAYS = 60; +const READ_CONCURRENCY = 16; + +export interface Contact { + name: string; + email: string; + count: number; + lastSeenMs: number; +} + +interface IndexEntry { + name: string; + email: string; + count: number; + lastSeenMs: number; + nameCounts: Map; +} + +let cachedIndex: Map | null = null; +let cachedAt = 0; +let pendingRebuild: Promise> | null = null; + +function parseAddressList(header: string): Array<{ name: string; email: string }> { + if (!header) return []; + const parts: string[] = []; + let buf = ''; + let inQuotes = false; + let inBrackets = 0; + for (const ch of header) { + if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes; + else if (ch === '<') inBrackets++; + else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1); + if (ch === ',' && !inQuotes && inBrackets === 0) { + if (buf.trim()) parts.push(buf.trim()); + buf = ''; + } else { + buf += ch; + } + } + if (buf.trim()) parts.push(buf.trim()); + + const result: Array<{ name: string; email: string }> = []; + for (const part of parts) { + const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/); + if (angled) { + const name = angled[1].trim().replace(/^"|"$/g, '').trim(); + const email = angled[2].trim().toLowerCase(); + if (email.includes('@')) result.push({ name, email }); + } else if (part.includes('@')) { + result.push({ name: '', email: part.trim().toLowerCase() }); + } + } + return result; +} + +// Local-part aliases that are almost always automated/role addresses you don't +// compose a fresh message to. Matched as a whole segment of the local part +// (segments split on . _ - +). +const AUTOMATED_LOCAL_PARTS = new Set([ + 'noreply', 'no-reply', 'donotreply', 'do-not-reply', 'reply', + 'notifications', 'notification', 'notify', + 'alerts', 'alert', 'updates', 'update', + 'news', 'newsletter', 'newsletters', + 'info', 'information', 'hello', 'hi', 'hey', + 'welcome', 'onboarding', 'getstarted', + 'team', 'marketing', 'promo', 'promos', 'promotions', + 'offer', 'offers', 'deals', 'deal', + 'accounts', 'account', 'billing', 'invoices', 'statements', 'statement', + 'learn', 'learning', 'courses', + 'mailer-daemon', 'mailerdaemon', 'postmaster', 'bounce', 'bounces', + 'automated', 'auto', 'autoconfirm', + 'support-bot', 'noticeboard', 'system', + 'contact', 'connect', + 'sender', 'broadcast', 'digest', 'campaign', 'campaigns', + 'support', 'service', 'help', 'helpdesk', 'feedback', + 'mailer', 'mailers', 'members', 'membership', + 'careers', 'jobs', 'recruit', 'recruiting', + 'tickets', 'orders', 'order', 'receipts', 'receipt', + 'applications', 'apply', 'admissions', + 'health', 'security', 'auth', +]); + +// Subdomain labels that flag a bulk/marketing infrastructure domain. +const AUTOMATED_SUBDOMAIN_LABELS = new Set([ + 'mail', 'mailer', 'mailers', 'mailing', 'mailgun', 'sendgrid', 'mta', + 'email', 'em', 'e', 'm', + 'news', 'newsletter', 'newsletters', + 'marketing', 'mkt', 'promo', 'promos', 'offers', + 'event', 'events', 'ecomm', 'commerce', + 'notifications', 'notification', 'notify', 'alerts', 'alert', 'updates', + 'messaging', 'message', 'msg', + 'noreply', 'donotreply', + 'creators', 'partners', 'team', + 'info', 'welcome', 'hi', 'hello', + 'bounces', 'bounce', + 'reply', 'user', 'usr', 'auto', +]); + +// Specific bulk-mail provider domains (substring match on full domain). +const AUTOMATED_DOMAIN_KEYWORDS = [ + 'facebookmail', 'kajabimail', 'substack', 'mailgun', 'sendgrid', + 'mcsv.net', 'mailchimp', 'mailerlite', 'createsend', 'cmail', + 'amazonses', 'sparkpost', 'sendinblue', 'brevo', + 'luma-mail', 'lumamail', + 'umusic-online', 'icloud-mail', +]; + +function localSegments(local: string): string[] { + return local.toLowerCase().split(/[._\-+]/).filter(Boolean); +} + +function isAutomatedAddress(email: string): boolean { + if (!email) return true; + const at = email.indexOf('@'); + if (at < 0) return true; + const local = email.slice(0, at).toLowerCase(); + const domain = email.slice(at + 1).toLowerCase(); + + // Plus-aliased reply bots: `reply+abc123@…` + if (/^reply\+/i.test(local)) return true; + + // Whole-segment local-part matches. + const segs = localSegments(local); + for (const s of segs) { + if (AUTOMATED_LOCAL_PARTS.has(s)) return true; + } + // Some senders pack noise into the local part with no separators + // (e.g. `hdfcbanksmartstatement`). Catch the common ones. + if (/(no.?reply|do.?not.?reply|notifications?|news.?letter|mailer.?daemon|postmaster|automated|broadcast|statement)/i.test(local)) { + return true; + } + + // Random-looking machine local parts: long, mostly hex/base32-ish. + if (local.length >= 20 && /^[a-z0-9]+(-[a-z0-9]+)*$/.test(local) && /[0-9]/.test(local)) { + const digits = (local.match(/[0-9]/g) || []).length; + if (digits / local.length >= 0.25) return true; + } + + // Subdomain-label check (everything except the registrable last two labels). + const labels = domain.split('.'); + if (labels.length >= 3) { + const subs = labels.slice(0, -2); + for (const label of subs) { + if (AUTOMATED_SUBDOMAIN_LABELS.has(label)) return true; + } + } + + // Provider keyword anywhere in the domain. + for (const kw of AUTOMATED_DOMAIN_KEYWORDS) { + if (domain.includes(kw)) return true; + } + + // Domain itself contains tell-tale tokens. + if (/(^|\.)(mailers?|mailer|mailgun|sendgrid|mailchimp|mailerlite|bounces?|marketing|promo|notifications?|newsletter)(\.|$)/i.test(domain)) { + return true; + } + + // Marketing-style TLD / second-level domain (e.g. bookmyshow.email, + // foo.marketing, bar.news). These domains exist almost exclusively for bulk. + const sld = labels[labels.length - 1]; + if (['email', 'mail', 'marketing', 'promo', 'news', 'newsletter', 'click', 'link'].includes(sld)) { + return true; + } + + // Brand-identity addresses like `uber@uber.com`, `lenovo@lenovo.com` — + // local part equals the first label of the domain. Almost always a + // transactional/marketing sender. + if (labels.length >= 2 && local === labels[0]) { + return true; + } + + return false; +} + +function ingestSnapshot(snapshot: GmailThreadSnapshot, selfEmail: string, map: Map): void { + if (!snapshot?.messages) return; + for (const msg of snapshot.messages) { + const parsed = msg.date ? Date.parse(msg.date) : NaN; + const ts = Number.isFinite(parsed) ? parsed : 0; + const fromAddrs = msg.from ? parseAddressList(msg.from) : []; + const sentBySelf = fromAddrs.some((a) => a.email === selfEmail); + + // Collect candidate contacts. For outbound mail, take recipients (the + // people *you* chose to write to — highest signal). For inbound mail, + // take the sender, but only if it doesn't look like a no-reply bot. + const candidates: Array<{ name: string; email: string }> = []; + if (sentBySelf) { + for (const h of [msg.to, msg.cc].filter(Boolean) as string[]) { + candidates.push(...parseAddressList(h)); + } + } else { + for (const a of fromAddrs) candidates.push(a); + } + + for (const { name, email } of candidates) { + if (!email || email === selfEmail) continue; + if (isAutomatedAddress(email)) continue; + let entry = map.get(email); + if (!entry) { + entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() }; + map.set(email, entry); + } + // Sent-to addresses carry stronger signal than inbound senders. + entry.count += sentBySelf ? 3 : 1; + if (ts > entry.lastSeenMs) entry.lastSeenMs = ts; + if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1); + } + } +} + +async function rebuildIndex(): Promise> { + const map = new Map(); + if (!fs.existsSync(CACHE_DIR)) return map; + + // Without a self email we can't tell which messages were sent by the user, + // so the index stays empty until Gmail is connected. + const selfRaw = await getAccountEmail().catch(() => null); + if (!selfRaw) return map; + const selfEmail = selfRaw.trim().toLowerCase(); + + let names: string[]; + try { + names = await fsp.readdir(CACHE_DIR); + } catch { + return map; + } + + const files = names.filter((n) => n.endsWith('.json')); + // Cap concurrency so a huge inbox can't blow the FD table. + for (let i = 0; i < files.length; i += READ_CONCURRENCY) { + const slice = files.slice(i, i + READ_CONCURRENCY); + const chunks = await Promise.all( + slice.map(async (fname) => { + try { + return await fsp.readFile(path.join(CACHE_DIR, fname), 'utf-8'); + } catch { + return null; + } + }), + ); + for (const raw of chunks) { + if (!raw) continue; + try { + const wrapper = JSON.parse(raw) as { snapshot?: GmailThreadSnapshot }; + if (wrapper.snapshot) ingestSnapshot(wrapper.snapshot, selfEmail, map); + } catch { + continue; + } + } + } + + for (const entry of map.values()) { + let best = entry.name; + let bestN = 0; + for (const [n, c] of entry.nameCounts) { + if (c > bestN) { best = n; bestN = c; } + } + entry.name = best; + } + return map; +} + +async function getIndex(): Promise> { + const now = Date.now(); + const fresh = cachedIndex && now - cachedAt <= INDEX_TTL_MS; + if (fresh) return cachedIndex!; + + // Serve stale cache while a refresh runs in the background; only block when + // there's no cache at all. + if (!pendingRebuild) { + pendingRebuild = rebuildIndex().then((m) => { + cachedIndex = m; + cachedAt = Date.now(); + pendingRebuild = null; + return m; + }).catch((err) => { + pendingRebuild = null; + throw err; + }); + } + if (cachedIndex) return cachedIndex; + return pendingRebuild; +} + +export function invalidateContactIndex(): void { + cachedIndex = null; + cachedAt = 0; +} + +// Warm the cache eagerly so the first user keystroke doesn't pay the cost. +export function warmContactIndex(): void { + void getIndex().catch(() => {}); +} + +function score(entry: IndexEntry, nowMs: number): number { + const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24)); + const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS); + return entry.count * (0.5 + 0.5 * recency); +} + +function matchTier(q: string, entry: IndexEntry): number { + if (!q) return 3; + const name = entry.name.toLowerCase(); + const email = entry.email; + if (name && name.startsWith(q)) return 0; + if (email.startsWith(q)) return 1; + if (name && name.includes(' ' + q)) return 1; + if (name && name.includes(q)) return 2; + if (email.includes(q)) return 3; + return -1; +} + +export interface SearchOpts { + limit?: number; + excludeEmails?: string[]; +} + +export async function searchContacts(query: string, opts: SearchOpts = {}): Promise { + const q = query.trim().toLowerCase(); + const limit = Math.max(1, Math.min(50, opts.limit ?? 8)); + const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase())); + + const index = await getIndex(); + const nowMs = Date.now(); + const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = []; + for (const entry of index.values()) { + if (excluded.has(entry.email)) continue; + const tier = matchTier(q, entry); + if (tier < 0) continue; + matches.push({ entry, tier, s: score(entry, nowMs) }); + } + matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s)); + return matches.slice(0, limit).map(({ entry }) => ({ + name: entry.name, + email: entry.email, + count: entry.count, + lastSeenMs: entry.lastSeenMs, + })); +} diff --git a/apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts b/apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts new file mode 100644 index 00000000..15ccf65e --- /dev/null +++ b/apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts @@ -0,0 +1,388 @@ +import fs from 'fs'; +import fsp from 'fs/promises'; +import path from 'path'; +import { google, gmail_v1 as gmail } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library'; +import { WorkDir } from '../config/config.js'; +import { GoogleClientFactory } from './google-client-factory.js'; +import { getUserEmail } from './classify_thread.js'; + +const STATE_FILE = path.join(WorkDir, 'contacts_sent.json'); +const RECENCY_HALFLIFE_DAYS = 60; +const HEADER_FETCH_CONCURRENCY = 8; +const REFRESH_INTERVAL_MS = 30 * 60 * 1000; + +export interface Contact { + name: string; + email: string; + count: number; + lastSeenMs: number; +} + +interface StoredEntry { + name: string; + email: string; + count: number; + lastSeenMs: number; + nameCounts: Record; +} + +interface StoredState { + version: 1; + historyId: string | null; + selfEmail: string | null; + lastFullSyncAt: number; + entries: StoredEntry[]; +} + +interface IndexEntry { + name: string; + email: string; + count: number; + lastSeenMs: number; + nameCounts: Map; +} + +let cachedIndex: Map | null = null; +let lastRefreshAt = 0; +let pendingSync: Promise | null = null; + +// Parses an address-list header value, respecting quoted display names and +// angle brackets ("Last, First" , …). +function parseAddressList(header: string): Array<{ name: string; email: string }> { + if (!header) return []; + const parts: string[] = []; + let buf = ''; + let inQuotes = false; + let inBrackets = 0; + for (const ch of header) { + if (ch === '"' && inBrackets === 0) inQuotes = !inQuotes; + else if (ch === '<') inBrackets++; + else if (ch === '>') inBrackets = Math.max(0, inBrackets - 1); + if (ch === ',' && !inQuotes && inBrackets === 0) { + if (buf.trim()) parts.push(buf.trim()); + buf = ''; + } else { + buf += ch; + } + } + if (buf.trim()) parts.push(buf.trim()); + + const out: Array<{ name: string; email: string }> = []; + for (const part of parts) { + const angled = part.match(/^(.*?)<\s*([^>]+?)\s*>\s*$/); + if (angled) { + const name = angled[1].trim().replace(/^"|"$/g, '').trim(); + const email = angled[2].trim().toLowerCase(); + if (email.includes('@')) out.push({ name, email }); + } else if (part.includes('@')) { + out.push({ name: '', email: part.trim().toLowerCase() }); + } + } + return out; +} + +function loadState(): StoredState | null { + try { + if (!fs.existsSync(STATE_FILE)) return null; + const raw = fs.readFileSync(STATE_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as StoredState; + if (parsed.version !== 1) return null; + return parsed; + } catch { + return null; + } +} + +async function saveState(state: StoredState): Promise { + const tmp = STATE_FILE + '.tmp'; + await fsp.mkdir(path.dirname(STATE_FILE), { recursive: true }); + await fsp.writeFile(tmp, JSON.stringify(state), 'utf-8'); + await fsp.rename(tmp, STATE_FILE); +} + +function indexFromStored(state: StoredState): Map { + const map = new Map(); + for (const e of state.entries) { + map.set(e.email, { + name: e.name, + email: e.email, + count: e.count, + lastSeenMs: e.lastSeenMs, + nameCounts: new Map(Object.entries(e.nameCounts || {})), + }); + } + return map; +} + +function storedFromIndex(map: Map, historyId: string | null, selfEmail: string | null, lastFullSyncAt: number): StoredState { + const entries: StoredEntry[] = []; + for (const e of map.values()) { + entries.push({ + name: e.name, + email: e.email, + count: e.count, + lastSeenMs: e.lastSeenMs, + nameCounts: Object.fromEntries(e.nameCounts), + }); + } + return { version: 1, historyId, selfEmail, lastFullSyncAt, entries }; +} + +function promoteCanonicalNames(map: Map): void { + for (const entry of map.values()) { + let best = entry.name; + let bestN = 0; + for (const [n, c] of entry.nameCounts) { + if (c > bestN) { best = n; bestN = c; } + } + entry.name = best; + } +} + +// Pulls the To/Cc/Date headers for a single sent message and folds the parsed +// recipients into the index. +async function ingestMessage( + client: gmail.Gmail, + messageId: string, + selfEmail: string, + map: Map, +): Promise { + const res = await client.users.messages.get({ + userId: 'me', + id: messageId, + format: 'metadata', + metadataHeaders: ['To', 'Cc', 'Date'], + }); + const headers = res.data.payload?.headers ?? []; + const headerValue = (name: string) => headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? ''; + + const dateStr = headerValue('Date'); + const parsedDate = dateStr ? Date.parse(dateStr) : NaN; + const ts = Number.isFinite(parsedDate) ? parsedDate : Date.now(); + + const recipients = [ + ...parseAddressList(headerValue('To')), + ...parseAddressList(headerValue('Cc')), + ]; + for (const { name, email } of recipients) { + if (!email || email === selfEmail) continue; + let entry = map.get(email); + if (!entry) { + entry = { name, email, count: 0, lastSeenMs: 0, nameCounts: new Map() }; + map.set(email, entry); + } + entry.count++; + if (ts > entry.lastSeenMs) entry.lastSeenMs = ts; + if (name) entry.nameCounts.set(name, (entry.nameCounts.get(name) || 0) + 1); + } +} + +async function processInBatches(items: T[], size: number, fn: (item: T) => Promise): Promise { + for (let i = 0; i < items.length; i += size) { + const slice = items.slice(i, i + size); + await Promise.all(slice.map(async (item) => { + try { await fn(item); } + catch { /* skip failed individual messages */ } + })); + } +} + +async function fullSync(auth: OAuth2Client, selfEmail: string): Promise<{ map: Map; historyId: string | null }> { + const client = google.gmail({ version: 'v1', auth }); + + // Lock in the current historyId BEFORE we start listing, so any messages + // sent during the sync get caught by the next incremental run. + let startingHistoryId: string | null = null; + try { + const profile = await client.users.getProfile({ userId: 'me' }); + startingHistoryId = profile.data.historyId ?? null; + } catch { + startingHistoryId = null; + } + + const messageIds: string[] = []; + let pageToken: string | undefined; + do { + const res = await client.users.messages.list({ + userId: 'me', + labelIds: ['SENT'], + maxResults: 500, + pageToken, + }); + for (const m of res.data.messages ?? []) { + if (m.id) messageIds.push(m.id); + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + const map = new Map(); + await processInBatches(messageIds, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map)); + promoteCanonicalNames(map); + return { map, historyId: startingHistoryId }; +} + +async function incrementalSync( + auth: OAuth2Client, + selfEmail: string, + startHistoryId: string, + map: Map, +): Promise<{ historyId: string | null; added: number } | null> { + const client = google.gmail({ version: 'v1', auth }); + const added: string[] = []; + let pageToken: string | undefined; + let latestHistoryId: string | null = null; + try { + do { + const res = await client.users.history.list({ + userId: 'me', + startHistoryId, + labelId: 'SENT', + historyTypes: ['messageAdded'], + maxResults: 500, + pageToken, + }); + for (const h of res.data.history ?? []) { + for (const m of h.messagesAdded ?? []) { + const labels = m.message?.labelIds ?? []; + const id = m.message?.id; + if (id && labels.includes('SENT')) added.push(id); + } + } + if (res.data.historyId) latestHistoryId = res.data.historyId; + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + } catch (err: unknown) { + // 404 means startHistoryId is too old — caller should fall back to full sync. + const status = (err as { code?: number; status?: number })?.code ?? (err as { code?: number; status?: number })?.status; + if (status === 404) return null; + throw err; + } + + // Dedupe in case the same message shows up in multiple history pages. + const unique = Array.from(new Set(added)); + await processInBatches(unique, HEADER_FETCH_CONCURRENCY, (id) => ingestMessage(client, id, selfEmail, map)); + if (unique.length > 0) promoteCanonicalNames(map); + + // If history.list returned no entries we have no fresh historyId; keep + // using the watermark we started from so the next call retries the same window. + return { historyId: latestHistoryId ?? startHistoryId, added: unique.length }; +} + +async function performSync(): Promise { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return; + const selfRaw = await getUserEmail(auth).catch(() => null); + if (!selfRaw) return; + const selfEmail = selfRaw.trim().toLowerCase(); + + const stored = loadState(); + const sameAccount = stored?.selfEmail === selfEmail; + + if (stored && sameAccount && stored.historyId) { + const map = indexFromStored(stored); + const result = await incrementalSync(auth, selfEmail, stored.historyId, map); + if (result) { + cachedIndex = map; + await saveState(storedFromIndex(map, result.historyId, selfEmail, stored.lastFullSyncAt)); + lastRefreshAt = Date.now(); + return; + } + // history watermark too old → fall through to full sync. + } + + const { map, historyId } = await fullSync(auth, selfEmail); + cachedIndex = map; + await saveState(storedFromIndex(map, historyId, selfEmail, Date.now())); + lastRefreshAt = Date.now(); +} + +function ensureFresh(): void { + if (pendingSync) return; + if (Date.now() - lastRefreshAt < REFRESH_INTERVAL_MS) return; + pendingSync = performSync() + .catch((err) => { + console.error('[gmail_sent_contacts] sync failed:', err instanceof Error ? err.message : err); + }) + .finally(() => { + pendingSync = null; + }); +} + +// Public: kick off a sync on app startup. Subsequent calls within the refresh +// window are no-ops. +export function warmSentContacts(): void { + if (!cachedIndex) { + const stored = loadState(); + if (stored) cachedIndex = indexFromStored(stored); + } + ensureFresh(); +} + +export function invalidateSentContacts(): void { + cachedIndex = null; + lastRefreshAt = 0; +} + +function score(entry: IndexEntry, nowMs: number): number { + const days = Math.max(0, (nowMs - entry.lastSeenMs) / (1000 * 60 * 60 * 24)); + const recency = Math.pow(0.5, days / RECENCY_HALFLIFE_DAYS); + return entry.count * (0.5 + 0.5 * recency); +} + +function matchTier(q: string, entry: IndexEntry): number { + if (!q) return 3; + const name = entry.name.toLowerCase(); + const email = entry.email; + if (name && name.startsWith(q)) return 0; + if (email.startsWith(q)) return 1; + if (name && name.includes(' ' + q)) return 1; + if (name && name.includes(q)) return 2; + if (email.includes(q)) return 3; + return -1; +} + +export interface SearchOpts { + limit?: number; + excludeEmails?: string[]; +} + +// Public: typeahead search over sent-recipient history. Returns instantly from +// the in-memory cache (or disk on first call) and triggers a background refresh. +export async function searchSentContacts(query: string, opts: SearchOpts = {}): Promise { + if (!cachedIndex) { + const stored = loadState(); + if (stored) cachedIndex = indexFromStored(stored); + } + // Kick off (or join) a background refresh; never block the user. + ensureFresh(); + + if (!cachedIndex) { + // First-ever launch: wait for the initial sync so we can return something + // useful instead of an empty list. + if (pendingSync) { + try { await pendingSync; } catch { /* return whatever we have */ } + } + if (!cachedIndex) return []; + } + + const q = query.trim().toLowerCase(); + const limit = Math.max(1, Math.min(50, opts.limit ?? 8)); + const excluded = new Set((opts.excludeEmails ?? []).map((e) => e.trim().toLowerCase())); + const nowMs = Date.now(); + + const matches: Array<{ entry: IndexEntry; tier: number; s: number }> = []; + for (const entry of cachedIndex.values()) { + if (excluded.has(entry.email)) continue; + const tier = matchTier(q, entry); + if (tier < 0) continue; + matches.push({ entry, tier, s: score(entry, nowMs) }); + } + matches.sort((a, b) => (a.tier - b.tier) || (b.s - a.s)); + return matches.slice(0, limit).map(({ entry }) => ({ + name: entry.name, + email: entry.email, + count: entry.count, + lastSeenMs: entry.lastSeenMs, + })); +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index f3acffa1..e694be2f 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -202,6 +202,21 @@ const ipcSchemas = { }), res: z.object({}), }, + 'gmail:searchContacts': { + req: z.object({ + query: z.string(), + limit: z.number().int().positive().optional(), + excludeEmails: z.array(z.string()).optional(), + }), + res: z.object({ + contacts: z.array(z.object({ + name: z.string(), + email: z.string(), + count: z.number(), + lastSeenMs: z.number(), + })), + }), + }, 'mcp:listTools': { req: z.object({ serverName: z.string(),