diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 89520999..2dc4aadf 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -74,7 +74,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; 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, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, 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 { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; @@ -782,6 +782,18 @@ export function setupIpcHandlers() { 'gmail:sendReply': async (_event, args) => { return sendThreadReply(args); }, + 'gmail:saveDraft': async (_event, args) => { + return saveThreadDraft(args); + }, + 'gmail:deleteDraft': async (_event, args) => { + return deleteThreadDraft(args.draftId); + }, + 'gmail:getDrafts': async () => { + return listDraftThreads(); + }, + 'gmail:search': async (_event, args) => { + return searchThreads(args.query, { limit: args.limit }); + }, 'gmail:getConnectionStatus': async () => { return getGmailConnectionStatus(); }, @@ -798,7 +810,10 @@ export function setupIpcHandlers() { return trashThread(args.threadId); }, 'gmail:markThreadRead': async (_event, args) => { - return markThreadRead(args.threadId); + return markThreadRead(args.threadId, args.read); + }, + 'gmail:downloadAttachment': async (_event, args) => { + return downloadAttachment(args); }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 1fd8c287..d9dff562 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -169,6 +169,25 @@ color: var(--gm-placeholder); } +.gmail-search-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + border-radius: 4px; + background: transparent; + color: var(--gm-text-muted); + cursor: pointer; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-search-clear:hover { + background: var(--gm-icon-hover-bg); + color: var(--gm-text); +} + .gmail-icon-button { display: inline-flex; align-items: center; @@ -205,6 +224,46 @@ flex-direction: column; } +/* Native list virtualization: offscreen rows skip layout and paint entirely. + Applied only to rows without a mounted ThreadDetail (those hold iframes and + composers, which must keep rendering while offscreen). */ +.gmail-row-group-cv { + content-visibility: auto; + contain-intrinsic-size: auto 40px; +} + +/* While the list is scrolling, rows ignore the pointer so hover restyles and + prefetch timers don't compete with frame rendering. */ +.gmail-shell[data-scrolling] .gmail-row-shell { + pointer-events: none; +} + +/* Archived/trashed rows slide out and collapse before removal. Removing the + class (failed action) snaps the row back. */ +.gmail-row-group-leaving { + overflow: hidden; + pointer-events: none; + animation: gmail-row-leave 160ms ease-in forwards; +} + +@keyframes gmail-row-leave { + 0% { + opacity: 1; + transform: translateX(0); + max-height: 48px; + } + 60% { + opacity: 0; + transform: translateX(32px); + max-height: 48px; + } + 100% { + opacity: 0; + transform: translateX(32px); + max-height: 0; + } +} + .gmail-list-header { position: sticky; top: 0; @@ -319,6 +378,20 @@ background: var(--gm-bg-row-selected-hover); } +/* The j/k keyboard cursor. Declared after the hover rules (same specificity) + so the focus ring survives hovering the focused row. */ +.gmail-row-focused, +.gmail-row-focused:hover { + background: var(--gm-bg-row-hover); + box-shadow: inset 0 0 0 1px var(--gm-accent); +} + +.gmail-row-selected.gmail-row-focused, +.gmail-row-selected.gmail-row-focused:hover { + background: var(--gm-bg-row-selected); + box-shadow: inset 2px 0 0 var(--gm-accent), inset 0 0 0 1px var(--gm-accent); +} + .gmail-row-unread { color: var(--gm-text); } @@ -394,12 +467,50 @@ border-top: 1px solid var(--gm-border); border-bottom: 1px solid var(--gm-border); box-shadow: inset 2px 0 0 var(--gm-accent); + /* Replays whenever the detail is shown — hidden details are display: none, + so un-hiding restarts the animation. */ + animation: gmail-detail-open 140ms ease-out; } .gmail-detail-hidden { display: none; } +@keyframes gmail-detail-open { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: none; + } +} + +/* The inline reply/forward composer pops in from below the thread. */ +.gmail-compose-inline { + animation: gmail-compose-open 140ms ease-out; +} + +@keyframes gmail-compose-open { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .gmail-detail-inline, + .gmail-compose-inline, + .gmail-row-group-leaving { + animation: none; + } +} + .gmail-detail-toolbar { display: flex; align-items: center; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 8815723f..b2e7b4f1 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -166,6 +166,11 @@ function buildRecipients( selfEmail: string, ): { to: string[]; cc: string[] } { if (mode === 'forward') return { to: [], cc: [] } + // Editing an existing draft: recipients are whatever the draft already has. + if (mode === 'draft') { + const draftMsg = latestMessage(thread) + return { to: splitAddresses(draftMsg?.to), cc: splitAddresses(draftMsg?.cc) } + } const latest = latestMessage(thread) const self = selfEmail.toLowerCase() @@ -192,9 +197,41 @@ function buildRecipients( return { to, cc } } +// Reply-chain headers (and thread placement) for the outgoing message. +// Replies rebuild the chain from the thread's messages. An edited draft is a +// single-message pseudo-thread whose own Message-ID must never be referenced +// (it dies on send, which would break recipients' threading) — reuse the +// In-Reply-To/References the draft already carries instead. Forwards and new +// messages start fresh. +function threadingHeaders( + mode: ComposeMode, + thread: GmailThread | undefined, +): { threadId?: string; inReplyTo?: string; references?: string } { + if (!thread || mode === 'forward' || mode === 'new') return {} + if (mode === 'draft') { + const draftMsg = latestMessage(thread) + return { + // Only a reply draft stays on its thread — a standalone draft's + // threadId is the phantom thread holding just the draft itself. + threadId: draftMsg?.inReplyToHeader ? thread.threadId : undefined, + inReplyTo: draftMsg?.inReplyToHeader, + references: draftMsg?.referencesHeader, + } + } + const messageIds = thread.messages + .map((m) => m.messageIdHeader) + .filter((v): v is string => Boolean(v)) + return { + threadId: thread.threadId, + inReplyTo: latestMessage(thread)?.messageIdHeader, + references: messageIds.join(' ') || undefined, + } +} + // Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes. function composeSubject(mode: ComposeMode, rawSubject?: string): string { const raw = (rawSubject || '').trim() + if (mode === 'draft') return raw // keep the draft's own subject verbatim if (mode === 'forward') return /^fwd:/i.test(raw) ? raw : `Fwd: ${raw}`.trim() return /^re:/i.test(raw) ? raw : `Re: ${raw}`.trim() } @@ -401,6 +438,10 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t const [height, setHeight] = useState(message.bodyHeight ?? 80) const [hasQuote, setHasQuote] = useState(false) const [showQuotes, setShowQuotes] = useState(false) + // Read by handleLoad so a reload (theme switch rebuilds srcDoc) restores the + // expanded-quotes state on the fresh document. + const showQuotesRef = useRef(showQuotes) + useEffect(() => { showQuotesRef.current = showQuotes }, [showQuotes]) const adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml]) const srcDoc = useMemo( @@ -413,6 +454,16 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t const doc = iframe?.contentDocument if (!doc?.body) return setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]')) + if (showQuotesRef.current) doc.documentElement.dataset.showQuotes = 'true' + // Clicking into the email body focuses the iframe document, which would + // otherwise swallow every list/thread shortcut (the parent's document + // keydown listeners never fire). The sandbox has allow-same-origin but no + // allow-scripts, so we forward from out here; the listener dies with the + // document on the next load. + doc.addEventListener('keydown', (event) => { + const clone = new KeyboardEvent('keydown', event) + if (!document.dispatchEvent(clone)) event.preventDefault() + }) const measure = () => { // Measure off body only. documentElement.scrollHeight stretches to fill // the iframe viewport, so once we size the iframe up (e.g. user expanded @@ -492,16 +543,32 @@ function formatAttachmentSize(bytes?: number): string { } function MessageAttachments({ attachments }: { attachments: NonNullable }) { - const openAttachment = (path: string, filename: string) => { - void window.ipc - .invoke('shell:openPath', { path }) - .then((result) => { - if (result?.error) toast(`Could not open ${filename}: ${result.error}`, 'error') - }) - .catch((err) => { - const message = err instanceof Error ? err.message : String(err) - toast(`Could not open ${filename}: ${message}`, 'error') - }) + const openAttachment = async (att: NonNullable[number]) => { + try { + // Ensure the file is on disk before handing off to the OS opener. Inbox + // attachments are saved during sync, but search-result attachments are + // only fetched on demand. gmail:downloadAttachment short-circuits when the + // file already exists, so calling it first is cheap and guarantees the + // file is present — we can't rely on shell:openPath reporting a missing + // file as an error (xdg-open on Linux reports success even when the path + // doesn't exist, so the old open-then-download fallback never fired). + if (att.messageId) { + const dl = await window.ipc.invoke('gmail:downloadAttachment', { + messageId: att.messageId, + savedPath: att.savedPath, + attachmentId: att.attachmentId, + }) + if (!dl.ok) { + toast(`Could not download ${att.filename}: ${dl.error ?? 'unknown error'}`, 'error') + return + } + } + const result = await window.ipc.invoke('shell:openPath', { path: att.savedPath }) + if (result?.error) toast(`Could not open ${att.filename}: ${result.error}`, 'error') + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + toast(`Could not open ${att.filename}: ${message}`, 'error') + } } return ( @@ -513,7 +580,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable openAttachment(att.savedPath, att.filename)} + onClick={() => void openAttachment(att)} title={`Open ${att.filename}`} > @@ -526,7 +593,20 @@ function MessageAttachments({ attachments }: { attachments: NonNullable void }) { + // Content-sized (no flex-1: a 0 basis always "fits" the current flex line, + // so the toolbar would never wrap — it would get squeezed until its + // fixed-size buttons overflowed the neighbors). The inner flex-wrap stacks + // the buttons if even a full line is too narrow to hold them. return ( -
+
))}
- {(isNew || mode === 'forward') && ( + {(isModal || mode === 'forward') && (
Subject Cancel
)} -
-
+ {/* flex-wrap: every button here is shrink-0, so on a narrow pane the + formatting toolbar wraps to its own line instead of overflowing + into (and clipping) the Discard button. */} +
+
- {editor && } - + {/* Toolbar + Discard share one wrap unit so Discard never strands on + a line of its own: wide panes show a single row (unit grows, so + ml-auto pins Discard to the right edge); narrow panes wrap the + whole unit to a second full-width row with the same alignment. + flex-auto (content basis) is what makes the unit wrap at all — + a flex-1 zero basis would "fit" forever and squeeze instead. */} +
+ {editor && } + +
) - if (isNew) { + if (isModal) { return ( - { if (!open) onClose() }}> + { if (!open) handleClose() }}> { + // Radix's Escape runs document-capture, before the inner fields + // can claim it — let the link bar and an open recipient-suggestion + // menu win; only a bare Escape closes the whole composer. + if (linkOpen) { + event.preventDefault() + cancelLink() + return + } + const target = event.target as HTMLElement | null + if (target?.closest('[data-suggestions-open]')) event.preventDefault() + }} >
{modeLabel} @@ -1548,7 +1929,7 @@ const ComposeBox = memo(function ComposeBox({ variant="ghost" size="icon-sm" className="size-7 text-muted-foreground" - onClick={onClose} + onClick={handleClose} aria-label="Close compose" > @@ -1561,7 +1942,10 @@ const ComposeBox = memo(function ComposeBox({ } return ( -
+
{modeLabel} +
+ + + +
+
+ {/* Drop the detail as soon as the row starts leaving — the collapse + keyframe assumes row height, and the thread is being removed anyway. */} + {isMounted && !isLeaving && ( +
+ ) +}) + +function ShortcutKey({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +// One shortcut line: `combo` keys render as adjacent chips (a chord or a +// two-key sequence); `alt` is an equivalent alternative shown after "or". +function ShortcutRow({ combo, alt, label }: { combo: string[]; alt?: string[]; label: string }) { + return ( +
+ {label} + + {combo.map((key) => {key})} + {alt && ( + <> + or + {alt.map((key) => {key})} + + )} + +
+ ) +} + +function ShortcutSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+ {children} +
+ ) +} + +// The "?" cheat sheet. Static list — keep in sync with the handlers in +// EmailView, ThreadDetail, and ComposeBox. +function ShortcutsHelpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const mod = isMac ? '⌘' : 'Ctrl' + return ( + + + Keyboard shortcuts +
+ + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+
+
+
+ ) +} + const MAX_KEPT_OPEN = 5 const PAGE_SIZE = 25 +// Duration of the row slide-out on archive/trash — matches gmail-row-leave. +const ROW_LEAVE_MS = 160 +// Sticky .gmail-list-header height — rows scrolled to the top stay clear of it. +const LIST_STICKY_HEADER_PX = 32 + +function listScrollerFor(row: HTMLElement): HTMLElement | null { + const list = row.closest('.gmail-list') + return list instanceof HTMLElement ? list : null +} type InboxSection = 'important' | 'other' interface SectionState { @@ -1731,6 +2353,8 @@ const initialSectionState: SectionState = { // panels and coming back doesn't reload from scratch. let persistedImportant: SectionState | null = null let persistedOther: SectionState | null = null +// Last-loaded drafts, kept across EmailView remounts so reopening is instant. +let persistedDrafts: GmailThread[] | null = null function clearLoadingFlag(state: SectionState | null): SectionState { if (!state) return initialSectionState @@ -1752,6 +2376,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [openedThreadIds, setOpenedThreadIds] = useState(initialThreadId ? [initialThreadId] : []) useEffect(() => { setSelectedThreadId(initialThreadId ?? null) + setFocusedThreadId(initialThreadId ?? null) if (initialThreadId) { setOpenedThreadIds((prev) => { const without = prev.filter((id) => id !== initialThreadId) @@ -1765,9 +2390,114 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [composeOpen, setComposeOpen] = useState(false) // Stable so the open composer isn't re-rendered on every inbox sync tick. const closeCompose = useCallback(() => setComposeOpen(false), []) + // Inbox vs Drafts. Drafts are fetched live (they're not in the inbox cache). + const [view, setView] = useState<'inbox' | 'drafts'>('inbox') + const [drafts, setDrafts] = useState(() => persistedDrafts ?? []) + const [draftsLoading, setDraftsLoading] = useState(false) + const [draftsError, setDraftsError] = useState(null) + const [editingDraft, setEditingDraft] = useState(null) + // Server-side search across the whole Gmail mailbox (results indexed locally). + const [searchResults, setSearchResults] = useState([]) + const [searching, setSearching] = useState(false) + const [searchError, setSearchError] = useState(null) + const searchEpoch = useRef(0) // Gmail sync uses the native Google OAuth connection. const [emailConnection, setEmailConnection] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) + // Keyboard navigation: the j/k focus cursor over the visible rows, plus the + // "?" shortcuts overlay. lastFocusedIndexRef remembers the cursor's position + // so it can re-anchor when the focused row disappears (archive/trash/reload). + const [focusedThreadId, setFocusedThreadId] = useState(null) + const [helpOpen, setHelpOpen] = useState(false) + // Set while the visible ThreadDetail's inline composer is open; list + // shortcuts stay inert so typing a reply can't archive threads. + const [activeThreadComposing, setActiveThreadComposing] = useState(false) + const searchInputRef = useRef(null) + const rootRef = useRef(null) + const lastFocusedIndexRef = useRef(0) + const listModeRef = useRef(null) + // Timestamp of a pending "g" for the two-key g→i / g→d sequences. + const gPendingRef = useRef(0) + // Rows currently animating out (archive/trash/draft-delete in flight). + const [leavingThreadIds, setLeavingThreadIds] = useState>(() => new Set()) + const markLeaving = useCallback((rowId: string, leaving: boolean) => { + setLeavingThreadIds((prev) => { + if (prev.has(rowId) === leaving) return prev + const next = new Set(prev) + if (leaving) next.add(rowId) + else next.delete(rowId) + return next + }) + }, []) + + const loadDrafts = useCallback(async () => { + setDraftsLoading(true) + setDraftsError(null) + try { + const res = await window.ipc.invoke('gmail:getDrafts', {}) + if (res.error) setDraftsError(res.error) + setDrafts(res.threads ?? []) + } catch (err) { + setDraftsError(err instanceof Error ? err.message : String(err)) + } finally { + setDraftsLoading(false) + } + }, []) + + // Load drafts when the Drafts view is opened. + useEffect(() => { + if (view === 'drafts') void loadDrafts() + }, [view, loadDrafts]) + + // Debounced full-mailbox search. Each keystroke bumps an epoch so stale + // responses are ignored; an empty query clears results. + useEffect(() => { + const q = query.trim() + searchEpoch.current += 1 + const epoch = searchEpoch.current + if (!q) { + setSearchResults([]) + setSearchError(null) + setSearching(false) + return + } + setSearching(true) + const handle = window.setTimeout(async () => { + try { + const res = await window.ipc.invoke('gmail:search', { query: q, limit: 100 }) + if (searchEpoch.current !== epoch) return + setSearchError(res.error ?? null) + setSearchResults(res.threads ?? []) + } catch (err) { + if (searchEpoch.current === epoch) setSearchError(err instanceof Error ? err.message : String(err)) + } finally { + if (searchEpoch.current === epoch) setSearching(false) + } + }, 400) + return () => window.clearTimeout(handle) + }, [query]) + + const deleteDraftAction = useCallback(async (thread: GmailThread) => { + const id = thread.draftId + if (!id) return + // Slide the row out before dropping it from the list. + markLeaving(id, true) + await new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)) + setDrafts((prev) => prev.filter((d) => d.draftId !== id)) + markLeaving(id, false) + try { + await window.ipc.invoke('gmail:deleteDraft', { draftId: id }) + } catch (err) { + toast(`Could not delete draft: ${err instanceof Error ? err.message : String(err)}`, 'error') + void loadDrafts() + } + }, [loadDrafts, markLeaving]) + + // Closing the draft composer may have edited/sent/deleted it — refresh. + const closeDraftEditor = useCallback(() => { + setEditingDraft(null) + void loadDrafts() + }, [loadDrafts]) useEffect(() => { let cancelled = false @@ -1794,32 +2524,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } }, []) - // Gmail-style "n" to start a new message. EmailView only mounts while the - // inbox is open, so this is naturally scoped to that view. Ignored while - // typing in any field or when a dialog (compose/settings) is already up. - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key !== 'n' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing) return - if (composeOpen || settingsOpen) return - const target = e.target as HTMLElement | null - if ( - target && - (target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.tagName === 'SELECT' || - target.isContentEditable) - ) { - return - } - e.preventDefault() - setComposeOpen(true) - } - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [composeOpen, settingsOpen]) - useEffect(() => { persistedImportant = important }, [important]) useEffect(() => { persistedOther = other }, [other]) + useEffect(() => { persistedDrafts = drafts }, [drafts]) const setSection = useCallback((section: InboxSection, updater: (prev: SectionState) => SectionState) => { if (section === 'important') setImportant(updater) @@ -1833,6 +2540,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }) setImportant(mapSection) setOther(mapSection) + setSearchResults((prev) => prev.map((t) => (t.threadId === threadId ? updater(t) : t))) }, []) const removeThreadFromState = useCallback((threadId: string) => { @@ -1842,18 +2550,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }) setImportant(filterSection) setOther(filterSection) + setSearchResults((prev) => prev.filter((t) => t.threadId !== threadId)) setSelectedThreadId((current) => (current === threadId ? null : current)) setOpenedThreadIds((prev) => prev.filter((id) => id !== threadId)) }, []) - const markThreadReadAction = useCallback(async (threadId: string) => { + const markThreadReadAction = useCallback(async (threadId: string, read: boolean = true) => { updateThreadInState(threadId, (t) => ({ ...t, - unread: false, - messages: t.messages.map((m) => ({ ...m, unread: false })), + unread: !read, + messages: t.messages.map((m) => ({ ...m, unread: !read })), })) try { - const result = await window.ipc.invoke('gmail:markThreadRead', { threadId }) + const result = await window.ipc.invoke('gmail:markThreadRead', { threadId, read }) if (!result.ok && result.error) console.warn('[Gmail] mark-read failed:', result.error) } catch (err) { console.warn('[Gmail] mark-read failed:', err) @@ -1861,8 +2570,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }, [updateThreadInState]) const archiveThreadAction = useCallback(async (threadId: string) => { + // Start the slide-out right away; the row is removed once both the IPC + // and the animation have finished. A failure clears the flag → snap back. + markLeaving(threadId, true) try { - const result = await window.ipc.invoke('gmail:archiveThread', { threadId }) + const [result] = await Promise.all([ + window.ipc.invoke('gmail:archiveThread', { threadId }), + new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)), + ]) if (result.ok) { removeThreadFromState(threadId) } else if (result.error) { @@ -1870,12 +2585,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } } catch (err) { toast(`Archive failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + markLeaving(threadId, false) } - }, [removeThreadFromState]) + }, [removeThreadFromState, markLeaving]) const trashThreadAction = useCallback(async (threadId: string) => { + markLeaving(threadId, true) try { - const result = await window.ipc.invoke('gmail:trashThread', { threadId }) + const [result] = await Promise.all([ + window.ipc.invoke('gmail:trashThread', { threadId }), + new Promise((resolve) => window.setTimeout(resolve, ROW_LEAVE_MS)), + ]) if (result.ok) { removeThreadFromState(threadId) } else if (result.error) { @@ -1883,10 +2604,13 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } } catch (err) { toast(`Delete failed: ${err instanceof Error ? err.message : String(err)}`, 'error') + } finally { + markLeaving(threadId, false) } - }, [removeThreadFromState]) + }, [removeThreadFromState, markLeaving]) const toggleThread = useCallback((thread: GmailThread) => { + setFocusedThreadId(thread.threadId) setSelectedThreadId((current) => { const next = current === thread.threadId ? null : thread.threadId if (next) { @@ -2151,98 +2875,374 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const visibleImportant = useMemo(() => filterThreads(important.threads), [important.threads, filterThreads]) const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads]) + const visibleDrafts = useMemo(() => filterThreads(drafts), [drafts, filterThreads]) + + // ── Keyboard shortcuts (Superhuman-style) ─────────────────────────────────── + // EmailView only mounts while the email tab is open, so these are naturally + // scoped to that view. Single-letter keys stay inert while typing in any + // field, while a dialog is up, or while the inline reply composer is open. + + const listMode: 'search' | 'drafts' | 'inbox' = query.trim() ? 'search' : view === 'drafts' ? 'drafts' : 'inbox' + const anyModalOpen = composeOpen || settingsOpen || Boolean(editingDraft) || helpOpen + + // Row identity for the focus cursor — must match each row's data-thread-id. + const rowIdOf = useCallback((thread: GmailThread) => ( + listMode === 'drafts' ? (thread.draftId || thread.threadId) : thread.threadId + ), [listMode]) + + // Flattened, ordered list of the rows currently on screen — the domain of + // the j/k cursor. Mirrors the render branches below: search results, drafts, + // or Important followed by Everything else (which only renders once + // Important is exhausted). + const visibleList = useMemo(() => { + if (query.trim()) return searchResults + if (view === 'drafts') return visibleDrafts + if (important.hasReachedEnd && other.threads.length > 0) return [...visibleImportant, ...visibleOther] + return visibleImportant + }, [query, searchResults, view, visibleDrafts, visibleImportant, visibleOther, important.hasReachedEnd, other.threads.length]) + + // Keep the cursor valid as the list changes: switching between inbox, + // search, and drafts resets it; if the focused row vanished (archived, + // trashed, or replaced by a live reload), re-anchor to the same position. + useEffect(() => { + if (listModeRef.current !== listMode) { + const isFirstRun = listModeRef.current === null + listModeRef.current = listMode + if (!isFirstRun) { + lastFocusedIndexRef.current = 0 + setFocusedThreadId(null) + return + } + } + if (!focusedThreadId || visibleList.length === 0) return + const idx = visibleList.findIndex((t) => rowIdOf(t) === focusedThreadId) + if (idx >= 0) { + lastFocusedIndexRef.current = idx + return + } + const fallback = visibleList[Math.min(lastFocusedIndexRef.current, visibleList.length - 1)] + setFocusedThreadId(fallback ? rowIdOf(fallback) : null) + }, [visibleList, focusedThreadId, listMode, rowIdOf]) + + // ── Smooth list scrolling ────────────────────────────────────────────────── + // Short ease-out scroll instead of native behavior:'smooth' — it finishes in + // ~140ms and retargets cleanly under key-repeat, where the native animation + // lags behind and rubber-bands. + const scrollAnimRef = useRef(null) + const smoothScrollTo = useCallback((container: HTMLElement, top: number) => { + if (scrollAnimRef.current !== null) window.cancelAnimationFrame(scrollAnimRef.current) + const from = container.scrollTop + const max = container.scrollHeight - container.clientHeight + const target = Math.min(Math.max(top, 0), max) + const delta = target - from + if (delta === 0) return + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + container.scrollTop = target + return + } + const start = performance.now() + const duration = 140 + const step = (now: number) => { + const t = Math.min(1, (now - start) / duration) + const eased = 1 - (1 - t) ** 3 + container.scrollTop = from + delta * eased + scrollAnimRef.current = t < 1 ? window.requestAnimationFrame(step) : null + } + scrollAnimRef.current = window.requestAnimationFrame(step) + }, []) + + useEffect(() => () => { + if (scrollAnimRef.current !== null) window.cancelAnimationFrame(scrollAnimRef.current) + }, []) + + const rowElementFor = useCallback((rowId: string) => { + const el = rootRef.current?.querySelector(`[data-thread-id="${CSS.escape(rowId)}"]`) + return el instanceof HTMLElement ? el : null + }, []) + + // Keep the focused row on screen during keyboard navigation — "nearest" + // semantics with the animated scroll, minding the sticky section header. + useEffect(() => { + if (!focusedThreadId) return + const row = rowElementFor(focusedThreadId) + const list = row ? listScrollerFor(row) : null + if (!row || !list) return + const listRect = list.getBoundingClientRect() + const rowRect = row.getBoundingClientRect() + const topEdge = listRect.top + LIST_STICKY_HEADER_PX + if (rowRect.top < topEdge) { + smoothScrollTo(list, list.scrollTop + rowRect.top - topEdge) + } else if (rowRect.bottom > listRect.bottom) { + smoothScrollTo(list, list.scrollTop + rowRect.bottom - listRect.bottom) + } + }, [focusedThreadId, rowElementFor, smoothScrollTo]) + + // Opening a thread glides its row to the top of the list so the expanded + // conversation gets the full viewport. Runs after the focus effect above and + // cancels its animation via the shared ref, so on open this scroll wins. + useEffect(() => { + if (!selectedThreadId) return + const row = rowElementFor(selectedThreadId) + const list = row ? listScrollerFor(row) : null + if (!row || !list) return + const rowTop = list.scrollTop + row.getBoundingClientRect().top - list.getBoundingClientRect().top + smoothScrollTo(list, rowTop - LIST_STICKY_HEADER_PX) + }, [selectedThreadId, rowElementFor, smoothScrollTo]) + + // While the list is scrolling, flag the shell so CSS turns off row pointer + // events (see .gmail-shell[data-scrolling]) — otherwise every row passing + // under the cursor restyles for :hover and schedules a prefetch timer, + // stealing frame time. The attribute is toggled straight on the DOM node so + // scrolling itself never causes a React render. + useEffect(() => { + const root = rootRef.current + if (!root) return + let timer: number | null = null + const onScroll = (event: Event) => { + const target = event.target + if (!(target instanceof Element) || !target.classList.contains('gmail-list')) return + if (timer === null) root.setAttribute('data-scrolling', '') + else window.clearTimeout(timer) + timer = window.setTimeout(() => { + timer = null + root.removeAttribute('data-scrolling') + }, 150) + } + // Capture phase: scroll events don't bubble, so listen above the scroller. + root.addEventListener('scroll', onScroll, { capture: true, passive: true }) + return () => { + root.removeEventListener('scroll', onScroll, { capture: true }) + if (timer !== null) window.clearTimeout(timer) + } + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.isComposing || e.defaultPrevented) return + // All list keys are unmodified. Shift is allowed through because "#" and + // "?" need it; shifted letters produce uppercase e.key and match nothing. + if (e.ctrlKey || e.metaKey || e.altKey) return + const inEditable = isEditableTarget(e.target) + + if (e.key === 'Escape') { + // The search input, recipient fields, and composers own their Escape; + // this level only closes the open thread or clears the search. + if (inEditable || anyModalOpen || activeThreadComposing) return + if (selectedThreadId) { + e.preventDefault() + setSelectedThreadId(null) + return + } + if (query.trim()) { + e.preventDefault() + setQuery('') + } + return + } + + if (inEditable || anyModalOpen || activeThreadComposing) return + + // Two-key "go to" sequences: g then i (inbox) / g then d (drafts). + if (gPendingRef.current && Date.now() - gPendingRef.current < 1000) { + gPendingRef.current = 0 + if (e.key === 'i' || e.key === 'd') { + e.preventDefault() + setQuery('') + setView(e.key === 'i' ? 'inbox' : 'drafts') + return + } + // Any other key cancels the sequence and is handled normally below. + } + if (e.key === 'g') { + gPendingRef.current = Date.now() + return + } + + const focusedIndex = focusedThreadId + ? visibleList.findIndex((t) => rowIdOf(t) === focusedThreadId) + : -1 + + const moveFocus = (delta: number) => { + if (visibleList.length === 0) return + e.preventDefault() // stop arrow keys from also scrolling the list + const next = visibleList[Math.min(Math.max(focusedIndex + delta, 0), visibleList.length - 1)] + if (next) setFocusedThreadId(rowIdOf(next)) + } + + switch (e.key) { + case 'j': + case 'ArrowDown': + moveFocus(1) + return + case 'k': + case 'ArrowUp': + moveFocus(-1) + return + case 'Enter': + case 'o': { + const focused = focusedIndex >= 0 ? visibleList[focusedIndex] : undefined + if (!focused) return + e.preventDefault() + if (listMode === 'drafts') setEditingDraft(focused) + else toggleThread(focused) + return + } + case 'c': + case 'n': + e.preventDefault() + setComposeOpen(true) + return + case '/': + e.preventDefault() + searchInputRef.current?.focus() + return + case '?': + e.preventDefault() + setHelpOpen(true) + return + case 'e': + case '#': + case 'u': { + if (listMode === 'drafts') return // drafts have no archive/trash/read state + // The open thread takes precedence over the cursor. + const targetId = selectedThreadId ?? (focusedIndex >= 0 ? visibleList[focusedIndex]?.threadId : undefined) + const target = targetId ? visibleList.find((t) => t.threadId === targetId) : undefined + if (!target) return + e.preventDefault() + if (e.key === 'u') void markThreadReadAction(target.threadId, target.unread === true) + else if (e.key === 'e') void archiveThreadAction(target.threadId) + else void trashThreadAction(target.threadId) + return + } + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [ + visibleList, focusedThreadId, selectedThreadId, query, listMode, anyModalOpen, + activeThreadComposing, rowIdOf, toggleThread, markThreadReadAction, + archiveThreadAction, trashThreadAction, + ]) const hasAny = important.threads.length > 0 || other.threads.length > 0 const initialLoading = !hasAny && refreshing const needsEmailConnect = emailConnection?.connected === false const needsEmailReconnect = emailConnection?.connected === true && !emailConnection.hasRequiredScope + const closeThread = useCallback(() => setSelectedThreadId(null), []) + const renderRow = (thread: GmailThread) => { - const latest = latestMessage(thread) - const isSelected = thread.threadId === selectedThreadId - const isUnread = thread.unread === true const isMounted = openedThreadIds.includes(thread.threadId) - const stop = (e: React.MouseEvent | React.KeyboardEvent) => { - e.stopPropagation() - } return ( -
-
scheduleHoverPrefetch(thread)} - onMouseLeave={cancelHoverPrefetch} - > + + ) + } + + const renderDraftRow = (thread: GmailThread) => { + const stop = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation() } + const recipient = thread.to ? extractName(thread.to) : 'No recipient' + const rowId = thread.draftId || thread.threadId + const isFocused = rowId === focusedThreadId + const isLeaving = leavingThreadIds.has(rowId) + return ( +
+
- {isUnread && ( - - )} -
- {isMounted && ( - setSelectedThreadId(null)} - hidden={!isSelected} - /> - )}
) } return ( -
+
setQuery(event.target.value)} - placeholder="Search loaded mail" + placeholder="Search all mail" + onKeyDown={(event) => { + if (event.key !== 'Escape') return + event.preventDefault() + event.stopPropagation() + if (query) setQuery('') + else event.currentTarget.blur() + }} /> + {query && ( + + )}
- + +
+
- {error && !hasAny ? ( + {query.trim() ? ( + searchError && searchResults.length === 0 ? ( +
Could not search: {searchError}
+ ) : searchResults.length > 0 ? ( +
+
+
+ Search results + {searchResults.length} thread{searchResults.length === 1 ? '' : 's'} +
+ {searchResults.map(renderRow)} +
+
+ ) : ( +
+ {searching ? 'Searching all mail…' : `No results for “${query.trim()}”.`} +
+ ) + ) : view === 'drafts' ? ( + draftsError && drafts.length === 0 ? ( +
Could not load drafts: {draftsError}
+ ) : drafts.length > 0 ? ( +
+
+
+ Drafts + {drafts.length} draft{drafts.length === 1 ? '' : 's'} +
+ {visibleDrafts.map(renderDraftRow)} +
+
+ ) : ( +
+ {draftsLoading ? 'Loading drafts…' : 'No drafts yet.'} +
+ ) + ) : error && !hasAny ? (
Could not load mail: {error}
) : hasAny ? (
@@ -2315,7 +3351,16 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = )}
{composeOpen && } + {editingDraft && ( + + )} +
) } diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index 7f230514..82b2021e 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -44,6 +44,17 @@ function ensureDefaultConfigs() { configured: false }, null, 2)); } + + // Create gmail_sync.json with the default onboarding email count if it + // doesn't exist, so the "how many emails to backfill" setting is + // discoverable and editable. Keep the default in sync with + // DEFAULT_MAX_EMAILS in gmail_sync_config.ts. + const gmailSyncConfig = path.join(WorkDir, "config", "gmail_sync.json"); + if (!fs.existsSync(gmailSyncConfig)) { + fs.writeFileSync(gmailSyncConfig, JSON.stringify({ + maxEmails: 500 + }, null, 2)); + } } ensureDirs(); diff --git a/apps/x/packages/core/src/config/gmail_sync_config.ts b/apps/x/packages/core/src/config/gmail_sync_config.ts new file mode 100644 index 00000000..d59be6cf --- /dev/null +++ b/apps/x/packages/core/src/config/gmail_sync_config.ts @@ -0,0 +1,69 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from './config.js'; + +const CONFIG_FILE = path.join(WorkDir, 'config', 'gmail_sync.json'); + +/** + * How many of the newest email threads the initial (onboarding) / recovery + * Gmail sync pulls down. This bounds the sync by a COUNT of recent threads + * rather than a fixed date window, so a fresh account backfills its most recent + * `maxEmails` emails even when they span more than a week. + */ +export const DEFAULT_MAX_EMAILS = 500; + +// Guard rails: at least one email, and a hard ceiling so a misconfigured value +// can't trigger a runaway onboarding sync (each thread costs a threads.get plus +// an LLM classification). +const MIN_MAX_EMAILS = 1; +const MAX_MAX_EMAILS = 5000; + +interface GmailSyncConfig { + maxEmails: number; +} + +function clampMaxEmails(value: number): number { + return Math.max(MIN_MAX_EMAILS, Math.min(MAX_MAX_EMAILS, Math.floor(value))); +} + +function readConfig(): Partial { + try { + if (fs.existsSync(CONFIG_FILE)) { + const raw = fs.readFileSync(CONFIG_FILE, 'utf-8'); + return JSON.parse(raw) as Partial; + } + } catch (err) { + console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err); + } + return {}; +} + +function writeConfig(config: Partial): void { + const configDir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +/** + * Read the configured max email count for the onboarding/full sync. + * Falls back to {@link DEFAULT_MAX_EMAILS} when the file is missing, malformed, + * or holds an out-of-range value. + */ +export function getMaxEmails(): number { + const value = Number(readConfig()?.maxEmails); + if (Number.isFinite(value) && value > 0) { + return clampMaxEmails(value); + } + return DEFAULT_MAX_EMAILS; +} + +/** + * Persist the max email count used by the onboarding/full sync. The value is + * clamped into the supported range before writing. + */ +export function setMaxEmails(maxEmails: number): void { + writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) }); +} + diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 6b4018ec..215429ce 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -4,6 +4,7 @@ import { google, gmail_v1 as gmail } from 'googleapis'; import { NodeHtmlMarkdown } from 'node-html-markdown' import { OAuth2Client } from 'google-auth-library'; import { WorkDir } from '../config/config.js'; +import { getMaxEmails } from '../config/gmail_sync_config.js'; import { GoogleClientFactory } from './google-client-factory.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; @@ -15,6 +16,10 @@ import { notifyIfEnabled } from '../application/notification/notifier.js'; const SYNC_DIR = path.join(WorkDir, 'gmail_sync'); const LEGACY_CACHE_DIR = path.join(SYNC_DIR, 'cache'); const CACHE_DIR = path.join(WorkDir, 'inbox_lists'); +// Local index of full-text search results. Kept separate from inbox_lists/ so it +// never leaks non-inbox threads into the inbox view. Grows as you search; we +// don't prune it (the user wants a durable local index). +const SEARCH_CACHE_DIR = path.join(WorkDir, 'search_index'); (function migrateLegacyCacheDir() { try { @@ -95,6 +100,35 @@ function deleteCachedSnapshot(threadId: string): void { } } +// Local search index — same on-disk shape as the inbox cache, separate dir. +function searchCachePath(threadId: string): string { + return path.join(SEARCH_CACHE_DIR, `${encodeURIComponent(threadId)}.json`); +} + +function readSearchSnapshot(threadId: string): SnapshotCacheEntry | null { + try { + const raw = fs.readFileSync(searchCachePath(threadId), 'utf-8'); + return JSON.parse(raw) as SnapshotCacheEntry; + } catch { + return null; + } +} + +function writeSearchSnapshot(threadId: string, historyId: string, snapshot: GmailThreadSnapshot): void { + try { + if (!fs.existsSync(SEARCH_CACHE_DIR)) fs.mkdirSync(SEARCH_CACHE_DIR, { recursive: true }); + const entry: SnapshotCacheEntry = { + historyId, + fetchedAt: new Date().toISOString(), + parserVersion: SNAPSHOT_PARSER_VERSION, + snapshot, + }; + fs.writeFileSync(searchCachePath(threadId), JSON.stringify(entry), 'utf-8'); + } catch (err) { + console.warn(`[Gmail search index] write failed for ${threadId}:`, err); + } +} + async function getGmailClientOrThrow() { const auth = await GoogleClientFactory.getClient(); if (!auth) throw new Error('Gmail is not connected.'); @@ -132,19 +166,19 @@ export async function trashThread(threadId: string): Promise } } -export async function markThreadRead(threadId: string): Promise { +export async function markThreadRead(threadId: string, read: boolean = true): Promise { try { const gmailClient = await getGmailClientOrThrow(); await gmailClient.users.threads.modify({ userId: 'me', id: threadId, - requestBody: { removeLabelIds: ['UNREAD'] }, + requestBody: read ? { removeLabelIds: ['UNREAD'] } : { addLabelIds: ['UNREAD'] }, }); - // Update local cache: clear unread on all messages in the thread. + // Mirror the new read state onto every message in the cached thread. const cached = readCachedSnapshot(threadId); if (cached) { - for (const m of cached.snapshot.messages) m.unread = false; - cached.snapshot.unread = false; + for (const m of cached.snapshot.messages) m.unread = !read; + cached.snapshot.unread = !read; try { fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8'); } catch (err) { @@ -176,6 +210,8 @@ export interface GmailThreadSnapshot { importance?: 'important' | 'other'; draft_response?: string; gmail_draft?: string; + /** Gmail-side draft id, present on entries from listDraftThreads. */ + draftId?: string; messages: Array<{ id?: string; from?: string; @@ -192,8 +228,19 @@ export interface GmailThreadSnapshot { mimeType?: string; sizeBytes?: number; savedPath: string; + messageId?: string; + attachmentId?: string; }>; messageIdHeader?: string; + isDraft?: boolean; + /** + * The draft's own stored In-Reply-To / References headers. Only set + * on draft messages (see buildDraftSnapshot) — the composer reuses + * them on send since the Drafts pseudo-thread has no other messages + * to rebuild the reply chain from. + */ + inReplyToHeader?: string; + referencesHeader?: string; }>; } @@ -347,6 +394,10 @@ interface ExtractedAttachment { mimeType?: string; sizeBytes?: number; savedPath: string; + // Gmail identifiers needed to fetch the attachment on demand (e.g. when a + // search result's attachment hasn't been downloaded to disk yet). + messageId?: string; + attachmentId?: string; } /** @@ -383,6 +434,8 @@ function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart, ht mimeType: part.mimeType ?? undefined, sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined, savedPath: `gmail_sync/attachments/${safeName}`, + messageId: msgId, + attachmentId: attId, }); } } @@ -793,6 +846,45 @@ async function buildAndCacheSnapshot( ) { return cached.snapshot; } + const snapshot = await parseThreadSnapshot(threadId, threadData, gmailClient); + if (!snapshot) return null; + + try { + const userEmail = await getUserEmail(auth); + const skipDraft = (snapshot.gmail_draft?.length ?? 0) > 0; + const classification = await classifyThread(snapshot, userEmail, { skipDraft }); + snapshot.importance = classification.importance; + if (classification.summary) snapshot.summary = classification.summary; + if (classification.draftResponse) { + const draftResponse = stripGmailQuotedReplyText(classification.draftResponse); + if (draftResponse) snapshot.draft_response = draftResponse; + } + } catch (err) { + console.warn(`[Gmail] classify failed for ${threadId}:`, err); + } + + if (threadData.historyId) { + writeCachedSnapshot(threadId, threadData.historyId, snapshot); + } + + return snapshot; +} + +/** + * Parse a threads.get response into a snapshot WITHOUT AI classification or + * caching — the shared core of buildAndCacheSnapshot, also used by search (which + * doesn't need importance/summary). Returns null when there are no visible + * (non-draft) messages. + */ +async function parseThreadSnapshot( + threadId: string, + threadData: gmail.Schema$Thread, + gmailClient: gmail.Gmail, +): Promise { + const messages = threadData.messages; + if (!messages || messages.length === 0) return null; + + const cached = readCachedSnapshot(threadId); const heightCarryover = new Map(); if (cached) { for (const m of cached.snapshot.messages) { @@ -856,7 +948,7 @@ async function buildAndCacheSnapshot( .filter(Boolean) .join('\n\n'); - const snapshot: GmailThreadSnapshot = { + return { threadId, threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`, subject: latest.subject || visibleMessages[0]?.subject, @@ -869,26 +961,6 @@ async function buildAndCacheSnapshot( messages: visibleMessages, gmail_draft: latestDraftBody || undefined, }; - - try { - const userEmail = await getUserEmail(auth); - const skipDraft = latestDraftBody.length > 0; - const classification = await classifyThread(snapshot, userEmail, { skipDraft }); - snapshot.importance = classification.importance; - if (classification.summary) snapshot.summary = classification.summary; - if (classification.draftResponse) { - const draftResponse = stripGmailQuotedReplyText(classification.draftResponse); - if (draftResponse) snapshot.draft_response = draftResponse; - } - } catch (err) { - console.warn(`[Gmail] classify failed for ${threadId}:`, err); - } - - if (threadData.historyId) { - writeCachedSnapshot(threadId, threadData.historyId, snapshot); - } - - return snapshot; } async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise { @@ -920,6 +992,83 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, return null; } +export interface DownloadAttachmentResult { + ok: boolean; + error?: string; +} + +/** + * Ensure an attachment referenced by a snapshot exists on disk, downloading it + * on demand when it doesn't. Inbox attachments are saved during sync, but + * search results build snapshots without downloading, so opening one of their + * attachments needs this. `savedPath` is the workspace-relative path stored on + * the attachment; `attachmentId` (when supplied) is tried first, falling back + * to re-fetching the message and locating the part by filename — attachment ids + * can go stale on a cached snapshot, whereas the file name is stable. + */ +export async function downloadAttachment(args: { + messageId: string; + savedPath: string; + attachmentId?: string; +}): Promise { + try { + const { messageId, savedPath, attachmentId } = args; + if (!messageId || !savedPath) return { ok: false, error: 'Missing attachment reference.' }; + + const absPath = path.join(WorkDir, savedPath); + if (fs.existsSync(absPath)) return { ok: true }; + + const gmailClient = await getGmailClientOrThrow(); + const dir = path.dirname(absPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const fetchData = async (attId: string): Promise => { + const res = await gmailClient.users.messages.attachments.get({ + userId: 'me', + messageId, + id: attId, + }); + return res.data.data ?? null; + }; + + let data: string | null = null; + if (attachmentId) { + try { + data = await fetchData(attachmentId); + } catch (err) { + console.warn(`[Gmail] attachment fetch by id failed for ${messageId}, retrying by filename:`, err); + } + } + + if (!data) { + // Re-fetch the message and locate the attachment part whose derived + // saved name matches the requested savedPath. + const wanted = path.basename(savedPath); + const msg = await gmailClient.users.messages.get({ userId: 'me', id: messageId, format: 'full' }); + let foundAttId: string | undefined; + const walk = (part: gmail.Schema$MessagePart): void => { + if (foundAttId) return; + const fn = part.filename; + const attId = part.body?.attachmentId; + if (fn && attId && `${messageId}_${cleanFilename(fn)}` === wanted) { + foundAttId = attId; + return; + } + if (part.parts) for (const sub of part.parts) walk(sub); + }; + if (msg.data.payload) walk(msg.data.payload); + if (!foundAttId) return { ok: false, error: 'Attachment not found in message.' }; + data = await fetchData(foundAttId); + } + + if (!data) return { ok: false, error: 'Attachment had no data.' }; + fs.writeFileSync(absPath, Buffer.from(data, 'base64')); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + // --- Sync Logic --- async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise { @@ -1149,24 +1298,29 @@ async function backfillMissingRecentThreads( async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { const gmail = google.gmail({ version: 'v1', auth }); - // If the state file holds a last_sync timestamp (e.g. left over from a - // prior Composio sync, or from a previous successful native sync that - // we're falling back to after a history.list 404), use that as the - // floor — but never reach back further than lookbackDays. This caps the - // window at "1 week at most": if last_sync is within the lookback window - // we resume from it (a smaller window), otherwise we clamp to lookbackDays - // ago. Mail older than the cap that arrived during a long offline gap is - // intentionally skipped rather than backfilled. + // The onboarding / recovery fetch is bounded by a COUNT of the most recent + // threads (maxEmails, configurable — default 500), not by a fixed date + // window. So a fresh account pulls its newest `maxEmails` emails even when + // they span more than a week. + // + // When we can resume after a previous successful sync (a last_sync within + // the lookback window — e.g. the history.list 404 fallback, or a prior + // Composio sync), we still floor the query at last_sync so only genuinely + // new mail is re-walked, and the count cap acts purely as a safety bound. + // With no resumable last_sync (first connect, or a gap longer than the + // lookback window) we drop the date floor entirely and just take the newest + // `maxEmails` threads. + const maxEmails = getMaxEmails(); const state = loadState(stateFile); const lookbackFloor = new Date(); lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays); - let pastDate: Date; - if (state.last_sync && new Date(state.last_sync) > lookbackFloor) { - pastDate = new Date(state.last_sync); - console.log(`Performing full sync from last_sync=${state.last_sync}...`); + const resumeFrom = state.last_sync && new Date(state.last_sync) > lookbackFloor + ? new Date(state.last_sync) + : null; + if (resumeFrom) { + console.log(`Performing full sync from last_sync=${state.last_sync} (max ${maxEmails} threads)...`); } else { - pastDate = lookbackFloor; - console.log(`Performing full sync of last ${lookbackDays} days...`); + console.log(`Performing full sync of the newest ${maxEmails} threads...`); } let run: ServiceRunContext | null = null; @@ -1181,19 +1335,24 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str }; try { - const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); + const baseQuery = '-in:spam -in:trash'; + const q = resumeFrom + ? `after:${resumeFrom.toISOString().split('T')[0].replace(/-/g, '/')} ${baseQuery}` + : baseQuery; // Get History ID const profile = await gmail.users.getProfile({ userId: 'me' }); const currentHistoryId = profile.data.historyId!; + // Gmail returns threads newest-first, so paginating until we've collected + // maxEmails ids yields the most recent maxEmails threads. const threadIds: string[] = []; let pageToken: string | undefined; do { const res = await gmail.users.threads.list({ userId: 'me', - q: `after:${dateQuery} -in:spam -in:trash`, - maxResults: 500, + q, + maxResults: Math.min(500, maxEmails), pageToken }); @@ -1206,7 +1365,9 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str } } pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); + } while (pageToken && threadIds.length < maxEmails); + + if (threadIds.length > maxEmails) threadIds.length = maxEmails; if (threadIds.length === 0) { saveState(currentHistoryId, stateFile); @@ -1318,6 +1479,14 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: for (const item of record.messagesAdded) { const labels = item.message?.labelIds ?? []; if (labels.includes('SPAM') || labels.includes('TRASH')) continue; + // Drafts are not incoming mail: every composer autosave + // (ours or another Gmail client's) adds a DRAFT message. + // Processing it would leak unsent draft bodies into + // gmail_sync/ markdown + knowledge events, fire "New + // email" notifications, and re-run the LLM classifier per + // autosave. The Drafts view reads live via gmail:getDrafts + // instead. + if (labels.includes('DRAFT')) continue; if (item.message?.threadId) { threadIds.add(item.message.threadId); } @@ -1435,10 +1604,10 @@ async function performSync() { // partial-sync on subsequent calls. const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0; // partialSync replays *every* messageAdded since the stored historyId, - // regardless of date — so after a long offline gap a still-valid - // historyId would pull the entire gap (e.g. 3 weeks). To honor the - // "1 week at most" cap, bypass it when last_sync is older than the - // lookback window and run a (date-clamped) fullSync instead. + // regardless of date/count — so after a long offline gap a still-valid + // historyId would pull the entire gap (e.g. 3 weeks). When last_sync is + // older than the lookback window, bypass it and run fullSync instead, + // which is count-bounded (the newest maxEmails threads). const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0; const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000; if (!state.historyId) { @@ -1448,7 +1617,7 @@ async function performSync() { console.log("History ID present but inbox cache empty — running full sync to backfill snapshots..."); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else if (gapTooLarge) { - console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`); + console.log(`Last sync older than ${LOOKBACK_DAYS} days — running count-bounded full sync instead of partial sync...`); await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } else { console.log("History ID found, starting partial sync..."); @@ -1486,6 +1655,19 @@ export interface SendReplyResult { error?: string; } +export interface SaveDraftOptions extends Omit { + /** Recipient may be blank while a draft is still being written. */ + to?: string; + /** Existing Gmail draft to update; omitted on first save (creates a new one). */ + draftId?: string; +} + +export interface SaveDraftResult { + /** The Gmail-side draft id, to be passed back on subsequent saves. */ + draftId?: string; + error?: string; +} + export interface GmailConnectionStatus { connected: boolean; hasRequiredScope: boolean; @@ -1589,6 +1771,88 @@ function sanitizeAttachmentName(name: string): string { return (name || 'attachment').replace(/[\r\n"\\]/g, '_').trim() || 'attachment'; } +// Build the raw (base64url) RFC 2822 message shared by both send and draft-save. +// Recipient headers are omitted when blank, so an in-progress draft with no +// `To` yet still produces a valid message. `isEmpty` lets callers reject a +// whitespace-only body without re-parsing the result. +function buildRawMimeMessage(opts: SaveDraftOptions, userEmail: string): { raw: string; isEmpty: boolean } { + const safeTo = opts.to?.trim() ? requireSafeHeaderValue('To', opts.to) : undefined; + const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined; + const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined; + const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined; + const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined; + const replyBody = opts.threadId + ? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText) + : { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() }; + const isEmpty = !replyBody.bodyText.trim(); + + const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + const altBoundary = `alt_${seed}`; + const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64); + + const headers: string[] = []; + headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`); + if (safeTo) headers.push(`To: ${safeTo}`); + if (safeCc) headers.push(`Cc: ${safeCc}`); + if (safeBcc) headers.push(`Bcc: ${safeBcc}`); + headers.push(`Subject: ${encodeRfc2047(opts.subject)}`); + if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`); + if (safeReferences) headers.push(`References: ${safeReferences}`); + headers.push('MIME-Version: 1.0'); + + // The text+html body as a self-contained multipart/alternative block. + const altParts: string[] = []; + altParts.push(`--${altBoundary}`); + altParts.push('Content-Type: text/plain; charset="UTF-8"'); + altParts.push('Content-Transfer-Encoding: base64'); + altParts.push(''); + altParts.push(encodeMimeBase64(replyBody.bodyText)); + altParts.push(''); + altParts.push(`--${altBoundary}`); + altParts.push('Content-Type: text/html; charset="UTF-8"'); + altParts.push('Content-Transfer-Encoding: base64'); + altParts.push(''); + altParts.push(encodeMimeBase64(replyBody.bodyHtml)); + altParts.push(''); + altParts.push(`--${altBoundary}--`); + + let body: string; + if (attachments.length) { + // Wrap the alternative body plus each attachment in a multipart/mixed. + const mixedBoundary = `mixed_${seed}`; + headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`); + const mixed: string[] = []; + mixed.push(`--${mixedBoundary}`); + mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); + mixed.push(''); + mixed.push(altParts.join('\r\n')); + for (const att of attachments) { + const name = sanitizeAttachmentName(att.filename); + const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream'; + mixed.push(`--${mixedBoundary}`); + mixed.push(`Content-Type: ${mime}; name="${name}"`); + mixed.push('Content-Transfer-Encoding: base64'); + mixed.push(`Content-Disposition: attachment; filename="${name}"`); + mixed.push(''); + mixed.push(wrapBase64(att.contentBase64)); + mixed.push(''); + } + mixed.push(`--${mixedBoundary}--`); + body = mixed.join('\r\n'); + } else { + headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); + body = altParts.join('\r\n'); + } + + const message = `${headers.join('\r\n')}\r\n\r\n${body}`; + const raw = Buffer.from(message, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return { raw, isEmpty }; +} + export async function sendThreadReply(opts: SendReplyOptions): Promise { try { const auth = await GoogleClientFactory.getClient(); @@ -1598,82 +1862,11 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise a.contentBase64); - - const headers: string[] = []; - headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`); - headers.push(`To: ${safeTo}`); - if (safeCc) headers.push(`Cc: ${safeCc}`); - if (safeBcc) headers.push(`Bcc: ${safeBcc}`); - headers.push(`Subject: ${encodeRfc2047(opts.subject)}`); - if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`); - if (safeReferences) headers.push(`References: ${safeReferences}`); - headers.push('MIME-Version: 1.0'); - - // The text+html body as a self-contained multipart/alternative block. - const altParts: string[] = []; - altParts.push(`--${altBoundary}`); - altParts.push('Content-Type: text/plain; charset="UTF-8"'); - altParts.push('Content-Transfer-Encoding: base64'); - altParts.push(''); - altParts.push(encodeMimeBase64(replyBody.bodyText)); - altParts.push(''); - altParts.push(`--${altBoundary}`); - altParts.push('Content-Type: text/html; charset="UTF-8"'); - altParts.push('Content-Transfer-Encoding: base64'); - altParts.push(''); - altParts.push(encodeMimeBase64(replyBody.bodyHtml)); - altParts.push(''); - altParts.push(`--${altBoundary}--`); - - let body: string; - if (attachments.length) { - // Wrap the alternative body plus each attachment in a multipart/mixed. - const mixedBoundary = `mixed_${seed}`; - headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`); - const mixed: string[] = []; - mixed.push(`--${mixedBoundary}`); - mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); - mixed.push(''); - mixed.push(altParts.join('\r\n')); - for (const att of attachments) { - const name = sanitizeAttachmentName(att.filename); - const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream'; - mixed.push(`--${mixedBoundary}`); - mixed.push(`Content-Type: ${mime}; name="${name}"`); - mixed.push('Content-Transfer-Encoding: base64'); - mixed.push(`Content-Disposition: attachment; filename="${name}"`); - mixed.push(''); - mixed.push(wrapBase64(att.contentBase64)); - mixed.push(''); - } - mixed.push(`--${mixedBoundary}--`); - body = mixed.join('\r\n'); - } else { - headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`); - body = altParts.join('\r\n'); - } - - const message = `${headers.join('\r\n')}\r\n\r\n${body}`; - const raw = Buffer.from(message, 'utf8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - const requestBody: gmail.Schema$Message = { raw }; + const requestBody: gmail.Schema$Message = { raw: built.raw }; if (opts.threadId) requestBody.threadId = opts.threadId; const res = await gmailClient.users.messages.send({ @@ -1707,6 +1900,294 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise { + try { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return { error: 'Gmail is not connected.' }; + + const gmailClient = google.gmail({ version: 'v1', auth }); + const userEmail = await getUserEmail(auth); + if (!userEmail) return { error: 'Could not determine your Gmail address.' }; + + const built = buildRawMimeMessage(opts, userEmail); + if (built.isEmpty) return { error: 'Draft is empty.' }; + + const message: gmail.Schema$Message = { raw: built.raw }; + if (opts.threadId) message.threadId = opts.threadId; + + // Resolve which draft to update: explicit id wins; otherwise reuse an + // existing draft on the same thread so replies don't pile up duplicates. + let draftId = opts.draftId; + if (!draftId && opts.threadId) { + try { + const drafts = await gmailClient.users.drafts.list({ userId: 'me' }); + const existing = (drafts.data.drafts || []).find( + (d) => d.message?.threadId === opts.threadId && d.id + ); + if (existing?.id) draftId = existing.id; + } catch { + // Listing failed — fall through and create a new draft. + } + } + + let res; + if (draftId) { + try { + res = await gmailClient.users.drafts.update({ + userId: 'me', + id: draftId, + requestBody: { message }, + }); + } catch (err) { + const code = (err as { code?: number })?.code + ?? (err as { response?: { status?: number } })?.response?.status; + // Recreate only when the draft is actually gone (deleted or + // already sent). A transient failure (timeout, 5xx) must NOT + // fall back to create — the original draft still exists, so + // that would silently pile up duplicates in Gmail. + if (code !== 404 && code !== 410) throw err; + res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } }); + } + } else { + res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } }); + } + + // Mirror the draft body onto the thread's cached snapshot so reopening + // the reply composer shows the autosaved text. Surgical, like + // markThreadRead — draft messages are filtered out of the history sync + // (see partialSync), so no sync pass will refresh this, and waking the + // whole sync loop per autosave (md/event writes + LLM reclassification) + // is exactly what we're avoiding. + if (opts.threadId) { + const cached = readCachedSnapshot(opts.threadId); + if (cached) { + cached.snapshot.gmail_draft = opts.bodyText?.trim() || undefined; + try { + fs.writeFileSync(cachePath(opts.threadId), JSON.stringify(cached), 'utf-8'); + } catch (err) { + console.warn(`[Gmail cache] draft write failed for ${opts.threadId}:`, err); + } + } + } + + return { draftId: res.data.id || undefined }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Delete a Gmail draft by id. A missing draft is treated as success. */ +export async function deleteThreadDraft(draftId: string): Promise<{ ok: boolean; error?: string }> { + try { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return { ok: false, error: 'Gmail is not connected.' }; + + const gmailClient = google.gmail({ version: 'v1', auth }); + await gmailClient.users.drafts.delete({ userId: 'me', id: draftId }); + triggerSync(); + return { ok: true }; + } catch (err) { + const code = (err as { code?: number; response?: { status?: number } })?.code + ?? (err as { response?: { status?: number } })?.response?.status; + // Already gone (sent/deleted) — nothing to do. + if (code === 404 || code === 410) return { ok: true }; + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +// In-memory cache of built draft snapshots, keyed by draftId and validated by +// the draft's underlying message id. Gmail assigns a fresh message id whenever a +// draft is updated (locally via saveThreadDraft or in another client), so an +// unchanged message id means the parsed snapshot can be reused — we skip the +// per-draft drafts.get + body parse, mirroring listInboxPage's mtime cache. +interface DraftCacheEntry { + messageId: string; + snapshot: GmailThreadSnapshot; +} +const draftListCache = new Map(); + +// Fetch one draft and parse it into a lightweight snapshot for the Drafts view. +async function buildDraftSnapshot( + gmailClient: gmail.Gmail, + draftId: string, +): Promise { + const full = await gmailClient.users.drafts.get({ userId: 'me', id: draftId, format: 'full' }); + const msg = full.data.message; + if (!msg) return null; + + const headers = msg.payload?.headers || []; + const parts = msg.payload ? extractBodyParts(msg.payload) : { text: '', html: '' }; + const rawBody = msg.payload ? normalizeBody(getBody(msg.payload)) : ''; + const body = stripGmailQuotedReplyText(rawBody); + const subject = headerValue(headers, 'Subject') || ''; + const from = headerValue(headers, 'From') || ''; + const to = headerValue(headers, 'To') || ''; + const cc = headerValue(headers, 'Cc') || ''; + const date = headerValue(headers, 'Date') || ''; + const threadId = msg.threadId || draftId; + const messageIdHeader = + headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined; + // The reply chain the draft already carries. The composer must reuse these + // on send — this pseudo-thread has no other messages to rebuild them from, + // and deriving them from the draft itself would self-reference a + // Message-ID that never gets delivered (breaking recipients' threading). + const inReplyToHeader = headerValue(headers, 'In-Reply-To') || undefined; + const referencesHeader = headerValue(headers, 'References') || undefined; + + return { + threadId, + threadUrl: `https://mail.google.com/mail/u/0/#drafts?compose=${draftId}`, + subject, + from, + to, + date, + latest_email: body, + gmail_draft: body || undefined, + draftId, + unread: false, + messages: [{ + id: msg.id || undefined, + from, + to, + cc: cc || undefined, + date, + subject, + body, + bodyHtml: parts.html || undefined, + messageIdHeader, + isDraft: true, + inReplyToHeader, + referencesHeader, + }], + }; +} + +/** + * List the account's Gmail drafts (reply drafts and standalone new-message + * drafts) as lightweight thread snapshots for the Drafts view. Drafts aren't + * part of the INBOX snapshot cache, so we read them from the Gmail API — but a + * cheap drafts.list (ids only) lets us reuse already-parsed snapshots for + * unchanged drafts and only drafts.get the new/edited ones. No AI + * classification; recipients/subject/body come straight off the draft message. + */ +export async function listDraftThreads(): Promise<{ threads: GmailThreadSnapshot[]; error?: string }> { + try { + const auth = await GoogleClientFactory.getClient(); + if (!auth) { + draftListCache.clear(); + return { threads: [], error: 'Gmail is not connected.' }; + } + + const gmailClient = google.gmail({ version: 'v1', auth }); + const list = await gmailClient.users.drafts.list({ userId: 'me', maxResults: 50 }); + const drafts = list.data.drafts || []; + + const seen = new Set(); + const built = await Promise.all(drafts.map(async (d) => { + if (!d.id) return null; + seen.add(d.id); + const messageId = d.message?.id || ''; + const cached = draftListCache.get(d.id); + // Reuse the cached snapshot when the draft's message id is unchanged. + if (cached && messageId && cached.messageId === messageId) { + return cached.snapshot; + } + try { + const snapshot = await buildDraftSnapshot(gmailClient, d.id); + if (snapshot) draftListCache.set(d.id, { messageId, snapshot }); + return snapshot; + } catch (err) { + console.warn('[Gmail] draft fetch failed:', err); + // Fall back to a stale cached copy if we have one. + return cached?.snapshot ?? null; + } + })); + + // Evict cache entries for drafts that no longer exist (sent/deleted). + for (const key of draftListCache.keys()) { + if (!seen.has(key)) draftListCache.delete(key); + } + + const threads = built.filter((s): s is GmailThreadSnapshot => s !== null); + // Newest first. + threads.sort((a, b) => { + const da = a.date ? Date.parse(a.date) : 0; + const db = b.date ? Date.parse(b.date) : 0; + return (Number.isFinite(db) ? db : 0) - (Number.isFinite(da) ? da : 0); + }); + return { threads }; + } catch (err) { + return { threads: [], error: err instanceof Error ? err.message : String(err) }; + } +} + +export interface SearchResult { + threads: GmailThreadSnapshot[]; + error?: string; +} + +/** + * Full-text search across the ENTIRE Gmail mailbox (not just locally-synced + * mail) using Gmail's `q` query. Each matching thread is parsed into a snapshot + * and written to the local search index so repeat searches — and opening a + * result — are instant. Reuses the inbox cache when a thread is already synced + * there. No AI classification. + */ +export async function searchThreads(query: string, opts: { limit?: number } = {}): Promise { + const q = query.trim(); + if (!q) return { threads: [] }; + try { + const auth = await GoogleClientFactory.getClient(); + if (!auth) return { threads: [], error: 'Gmail is not connected.' }; + + const gmailClient = google.gmail({ version: 'v1', auth }); + // Generous cap so the index isn't artificially small (Gmail allows 500). + const limit = Math.max(1, Math.min(200, opts.limit ?? 100)); + const list = await gmailClient.users.threads.list({ userId: 'me', q, maxResults: limit }); + const ids = (list.data.threads || []) + .map((t) => t.id) + .filter((id): id is string => Boolean(id)); + + const built = await Promise.all(ids.map(async (threadId) => { + // Prefer the inbox snapshot (kept fresh by sync), then the search index. + const inboxCached = readCachedSnapshot(threadId); + if (inboxCached?.snapshot) return inboxCached.snapshot; + const indexed = readSearchSnapshot(threadId); + if (indexed?.snapshot) return indexed.snapshot; + try { + const threadData = await gmailClient.users.threads.get({ userId: 'me', id: threadId, format: 'full' }); + const snapshot = await parseThreadSnapshot(threadId, threadData.data, gmailClient); + if (snapshot) writeSearchSnapshot(threadId, threadData.data.historyId || '', snapshot); + return snapshot; + } catch (err) { + console.warn(`[Gmail search] fetch failed for ${threadId}:`, err); + return null; + } + })); + + const threads = built.filter((s): s is GmailThreadSnapshot => s !== null); + // Newest first. + threads.sort((a, b) => { + const da = a.date ? Date.parse(a.date) : 0; + const db = b.date ? Date.parse(b.date) : 0; + return (Number.isFinite(db) ? db : 0) - (Number.isFinite(da) ? da : 0); + }); + return { threads }; + } catch (err) { + return { threads: [], error: err instanceof Error ? err.message : String(err) }; + } +} + export async function init() { console.log("Starting Gmail Sync (TS)..."); console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index 69b331c4..7f456bab 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -107,6 +107,10 @@ export const GmailAttachmentSchema = z.object({ mimeType: z.string().optional(), sizeBytes: z.number().int().nonnegative().optional(), savedPath: z.string(), + // Gmail identifiers used to fetch the attachment on demand when it hasn't + // been downloaded to disk yet (e.g. attachments on search results). + messageId: z.string().optional(), + attachmentId: z.string().optional(), }); export type GmailAttachment = z.infer; @@ -124,6 +128,14 @@ export const GmailThreadMessageSchema = z.object({ bodyHeight: z.number().int().positive().optional(), attachments: z.array(GmailAttachmentSchema).optional(), messageIdHeader: z.string().optional(), + // Set on the unsent draft message within a thread (used by the Drafts view). + isDraft: z.boolean().optional(), + // The draft's own stored In-Reply-To / References headers. Only set on + // draft messages: a Drafts-view pseudo-thread contains just the draft, so + // the composer can't rebuild the reply chain from thread messages and must + // reuse what the draft already carries. + inReplyToHeader: z.string().optional(), + referencesHeader: z.string().optional(), }); export type GmailThreadMessage = z.infer; @@ -134,6 +146,9 @@ export const GmailThreadSchema = EmailBlockSchema.extend({ unread: z.boolean().optional(), importance: z.enum(['important', 'other']).optional(), gmail_draft: z.string().optional(), + // Gmail-side draft id, present on entries returned by the Drafts list so the + // composer can update/delete that exact draft. + draftId: z.string().optional(), messages: z.array(GmailThreadMessageSchema), }); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index ba9062c2..6010b840 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -207,6 +207,56 @@ const ipcSchemas = { error: z.string().optional(), }), }, + 'gmail:saveDraft': { + req: z.object({ + // Existing Gmail draft to update; omitted on first save (creates a new one). + draftId: z.string().min(1).optional(), + threadId: z.string().min(1).optional(), + // Recipients may be blank for a draft (unlike a send). + to: z.string().optional(), + cc: z.string().optional(), + bcc: z.string().optional(), + subject: z.string(), + bodyHtml: z.string(), + bodyText: z.string(), + inReplyTo: z.string().optional(), + references: z.string().optional(), + attachments: z + .array( + z.object({ + filename: z.string(), + mimeType: z.string(), + contentBase64: z.string(), + }), + ) + .optional(), + }), + res: z.object({ + draftId: z.string().optional(), + error: z.string().optional(), + }), + }, + 'gmail:deleteDraft': { + req: z.object({ draftId: z.string().min(1) }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:getDrafts': { + req: z.object({}), + res: z.object({ + threads: z.array(GmailThreadSchema), + error: z.string().optional(), + }), + }, + 'gmail:search': { + req: z.object({ + query: z.string(), + limit: z.number().int().positive().optional(), + }), + res: z.object({ + threads: z.array(GmailThreadSchema), + error: z.string().optional(), + }), + }, 'gmail:getConnectionStatus': { req: z.object({}), res: z.object({ @@ -237,7 +287,15 @@ const ipcSchemas = { res: z.object({ ok: z.boolean(), error: z.string().optional() }), }, 'gmail:markThreadRead': { - req: z.object({ threadId: z.string().min(1) }), + req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }), + res: z.object({ ok: z.boolean(), error: z.string().optional() }), + }, + 'gmail:downloadAttachment': { + req: z.object({ + messageId: z.string().min(1), + savedPath: z.string().min(1), + attachmentId: z.string().optional(), + }), res: z.object({ ok: z.boolean(), error: z.string().optional() }), }, 'gmail:saveMessageHeight': {