mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
add Gmail contacts autocomplete to compose box (#607)
Adds a gmail:searchContacts IPC channel backed by two indices: a SENT-label API-backed index (gmail_sent_contacts) for full historical coverage of people you've actually emailed, and a local-snapshot fallback (gmail_contacts) used until the SENT sync finishes on first launch. Both indices warm at startup so the first keystroke in the recipient box is instant. Renderer wires the suggestions into the to/cc/bcc fields in email-view with styled chips. Co-authored-by: arkml <6592213+arkml@users.noreply.github.com>
This commit is contained in:
parent
0aec665220
commit
c48ef5ac0c
6 changed files with 1056 additions and 4 deletions
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 <mark>.
|
||||
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)}
|
||||
<mark className="gmail-recipient-suggestion-match">{text.slice(idx, idx + q.length)}</mark>
|
||||
{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<ContactSuggestion[]>([])
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [queryShown, setQueryShown] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const fieldRef = useRef<HTMLDivElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="gmail-recipient-row">
|
||||
<span className="gmail-recipient-label">{label}</span>
|
||||
<div className="gmail-recipient-field">
|
||||
<div className="gmail-recipient-field" ref={fieldRef}>
|
||||
{value.map((token, index) => (
|
||||
<span key={`${token}-${index}`} className="gmail-recipient-chip" title={extractAddress(token)}>
|
||||
<span className="gmail-recipient-chip-label">{recipientLabel(token)}</span>
|
||||
|
|
@ -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 && (
|
||||
<ul className="gmail-recipient-suggestions" role="listbox" ref={listRef}>
|
||||
{suggestions.map((c, idx) => {
|
||||
const hue = contactHue(c.email)
|
||||
return (
|
||||
<li
|
||||
key={c.email}
|
||||
role="option"
|
||||
aria-selected={idx === activeIndex}
|
||||
className={cn('gmail-recipient-suggestion', idx === activeIndex && 'is-active')}
|
||||
onMouseDown={(event) => {
|
||||
// Prevent input blur before click fires.
|
||||
event.preventDefault()
|
||||
pickSuggestion(c)
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
>
|
||||
<span
|
||||
className="gmail-recipient-suggestion-avatar"
|
||||
style={{ background: `hsl(${hue}, 60%, 42%)` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{contactInitial(c)}
|
||||
</span>
|
||||
<span className="gmail-recipient-suggestion-text">
|
||||
<span className="gmail-recipient-suggestion-name">
|
||||
<HighlightedText text={c.name || c.email} query={queryShown} />
|
||||
</span>
|
||||
{c.name && (
|
||||
<span className="gmail-recipient-suggestion-email">
|
||||
<HighlightedText text={c.email} query={queryShown} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{trailing && <div className="gmail-recipient-trailing">{trailing}</div>}
|
||||
</div>
|
||||
|
|
|
|||
348
apps/x/packages/core/src/knowledge/gmail_contacts.ts
Normal file
348
apps/x/packages/core/src/knowledge/gmail_contacts.ts
Normal file
|
|
@ -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<string, number>;
|
||||
}
|
||||
|
||||
let cachedIndex: Map<string, IndexEntry> | null = null;
|
||||
let cachedAt = 0;
|
||||
let pendingRebuild: Promise<Map<string, IndexEntry>> | 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<string, IndexEntry>): 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<Map<string, IndexEntry>> {
|
||||
const map = new Map<string, IndexEntry>();
|
||||
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<Map<string, IndexEntry>> {
|
||||
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<Contact[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
388
apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts
Normal file
388
apps/x/packages/core/src/knowledge/gmail_sent_contacts.ts
Normal file
|
|
@ -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<string, number>;
|
||||
}
|
||||
|
||||
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<string, number>;
|
||||
}
|
||||
|
||||
let cachedIndex: Map<string, IndexEntry> | null = null;
|
||||
let lastRefreshAt = 0;
|
||||
let pendingSync: Promise<void> | null = null;
|
||||
|
||||
// Parses an address-list header value, respecting quoted display names and
|
||||
// angle brackets ("Last, First" <a@b>, …).
|
||||
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<void> {
|
||||
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<string, IndexEntry> {
|
||||
const map = new Map<string, IndexEntry>();
|
||||
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<string, IndexEntry>, 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<string, IndexEntry>): 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<string, IndexEntry>,
|
||||
): Promise<void> {
|
||||
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<T>(items: T[], size: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
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<string, IndexEntry>; 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<string, IndexEntry>();
|
||||
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<string, IndexEntry>,
|
||||
): 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<void> {
|
||||
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<Contact[]> {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue