From ab9bce6203e751c4077735217e126d9737d062b1 Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Wed, 1 Jul 2026 16:18:52 +0530 Subject: [PATCH 1/4] feat(email): drafts, search, bulk read/unread, configurable backfill Gmail client enhancements in apps/x, bundling four independent features: - Drafts: save/autosave, update, delete, and list Gmail drafts. The composer autosaves to a real Gmail draft (debounced ~1.5s) while typing, reuses the thread's existing draft so edits update in place, flushes a final save on close, and deletes the draft on discard. Adds isDraft/draftId to the shared thread types. New core helpers: saveThreadDraft, deleteThreadDraft, listDraftThreads, buildDraftSnapshot, buildRawMimeMessage. - Search: searchThreads(query, {limit}) backed by an on-disk snapshot cache (read/writeSearchSnapshot). Extracts parseThreadSnapshot as the shared parse core reused by both the cache-building sync and search. - Read state: markThreadRead now takes a `read` flag so it toggles read/unread; new markSectionRead marks a whole section (important/other) read/unread and returns the affected count. - Backfill: the onboarding/recovery sync is now bounded by a configurable thread COUNT instead of a fixed 7-day window. New gmail_sync_config.ts (getMaxEmails/setMaxEmails) backed by ~/.rowboat/config/gmail_sync.json, default 500, clamped to 1-5000; seeded on first run. New IPC channels: gmail:saveDraft, gmail:deleteDraft, gmail:getDrafts, gmail:search, gmail:markSectionRead (plus a `read` field on gmail:markThreadRead). --- apps/x/apps/main/src/ipc.ts | 19 +- .../renderer/src/components/email-view.tsx | 488 ++++++++++++- apps/x/packages/core/src/config/config.ts | 11 + .../core/src/config/gmail_sync_config.ts | 61 ++ .../packages/core/src/knowledge/sync_gmail.ts | 674 ++++++++++++++---- apps/x/packages/shared/src/blocks.ts | 5 + apps/x/packages/shared/src/ipc.ts | 56 +- 7 files changed, 1149 insertions(+), 165 deletions(-) create mode 100644 apps/x/packages/core/src/config/gmail_sync_config.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3ed9f6a0..ae732230 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -69,7 +69,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, markSectionRead, 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'; @@ -740,6 +740,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(); }, @@ -756,7 +768,10 @@ export function setupIpcHandlers() { return trashThread(args.threadId); }, 'gmail:markThreadRead': async (_event, args) => { - return markThreadRead(args.threadId); + return markThreadRead(args.threadId, args.read); + }, + 'gmail:markSectionRead': async (_event, args) => { + return markSectionRead(args.section, args.read); }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 8815723f..f469e9bd 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -11,6 +11,7 @@ import { useTheme } from '@/contexts/theme-context' import { SettingsDialog } from '@/components/settings-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' type GmailThread = blocks.GmailThread @@ -166,6 +167,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() @@ -195,6 +201,7 @@ function buildRecipients( // 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() } @@ -526,7 +533,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable = { key: 'longer', label: 'Longer', instruction: 'Rewrite this email to be more detailed and thorough.' }, ] +// Debounce before autosaving the composer to a Gmail draft after the last edit. +const DRAFT_AUTOSAVE_MS = 1500 + +// Shape of the gmail:saveDraft request, kept here so we can stash a snapshot in +// a ref for the close/unmount flush without re-reading a torn-down editor. +type DraftPayload = { + draftId?: string + threadId?: string + to?: string + cc?: string + bcc?: string + subject: string + bodyHtml: string + bodyText: string + inReplyTo?: string + references?: string + attachments?: Array<{ filename: string; mimeType: string; contentBase64: string }> +} + // Composer for replies, forwards, and (mode 'new') from-scratch emails. With a // thread it renders as an inline card under the thread; in 'new' mode it has no // thread and renders as a centered modal with the AI writing bar. @@ -992,7 +1018,9 @@ const ComposeBox = memo(function ComposeBox({ onClose: () => void }) { const isNew = mode === 'new' - const latest = thread ? latestMessage(thread) : undefined + // Drafts and new messages share the full-modal layout (subject line, AI bar). + const isModal = isNew || mode === 'draft' + const latest = useMemo(() => (thread ? latestMessage(thread) : undefined), [thread]) const initialRecipients = useMemo( () => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }), [mode, thread, selfEmail], @@ -1004,11 +1032,16 @@ const ComposeBox = memo(function ComposeBox({ const [showCc, setShowCc] = useState(initialRecipients.cc.length > 0) const [showBcc, setShowBcc] = useState(false) const [subject, setSubject] = useState(() => (thread ? composeSubject(mode, thread.subject) : '')) - const modeLabel = isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply' + const modeLabel = mode === 'draft' ? 'Draft' : isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply' const initialContent = useMemo(() => { if (!thread) return '' if (mode === 'forward') return buildForwardedContent(thread) + // For a saved draft, reopen the exact HTML we stored so formatting survives. + if (mode === 'draft') { + const draftMsg = latestMessage(thread) + if (draftMsg?.bodyHtml) return draftMsg.bodyHtml + } // Gmail-side draft (user's own work) wins over the AI-generated draft. const source = stripQuotedReplyText(thread.gmail_draft || thread.draft_response || '') if (!source) return '' @@ -1023,7 +1056,7 @@ const ComposeBox = memo(function ComposeBox({ StarterKit.configure({ link: false }), Link.configure({ openOnClick: false, autolink: true }), Placeholder.configure({ - placeholder: isNew || mode === 'forward' ? 'Write a message…' : 'Write your reply…', + placeholder: isModal || mode === 'forward' ? 'Write a message…' : 'Write your reply…', }), ], editorProps: { @@ -1255,6 +1288,155 @@ const ComposeBox = memo(function ComposeBox({ setAttachments((prev) => prev.filter((a) => a.id !== id)) } + // ── Draft autosave ───────────────────────────────────────────────────────── + // Keep a real Gmail draft in sync with the composer while the user types + // (debounced) and flush a final save on close, so closing keeps the work in + // Gmail's Drafts folder (synced to every device) instead of discarding it. + // Empty/whitespace drafts are skipped. The core saveThreadDraft reuses an + // existing thread draft and tracks the id so edits update in place. + // Seeded when editing an existing draft so the first save updates it in place. + const draftIdRef = useRef(thread?.draftId) + const lastPayloadRef = useRef(null) + const savingRef = useRef(false) // a saveDraft IPC is in flight + const pendingRef = useRef(false) // edits arrived mid-flight; save once more + const sentRef = useRef(false) // suppress autosave after a successful send + const discardedRef = useRef(false) // suppress autosave after discard + const closedRef = useRef(false) // close already handled; skip unmount flush + const dirtyRef = useRef(false) // user has edited since open + const fieldsMounted = useRef(false) // skip the field effect's initial run + const autosaveTimer = useRef(null) + const saveDraftNowRef = useRef<() => Promise>(undefined) + + const buildDraftPayload = useCallback((): DraftPayload | null => { + if (!editor || editor.isDestroyed) return null + const text = editor.getText().trim() + if (!text) return null // skip empty/whitespace drafts + const html = editor.getHTML() + const messageIds = (thread?.messages ?? []) + .map((m) => m.messageIdHeader) + .filter((v): v is string => Boolean(v)) + const references = messageIds.join(' ') + const inReplyTo = latest?.messageIdHeader + // Only replies stay on the thread; forwards and new emails start fresh. + const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew + return { + draftId: draftIdRef.current, + threadId: isThreaded ? thread?.threadId : undefined, + to: toList.join(', '), + cc: ccList.length ? ccList.join(', ') : undefined, + bcc: bccList.length ? bccList.join(', ') : undefined, + subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : ''), + bodyHtml: html, + bodyText: text, + inReplyTo: isThreaded ? inReplyTo : undefined, + references: isThreaded ? references || undefined : undefined, + attachments: attachments.length + ? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 })) + : undefined, + } + }, [editor, thread, mode, isNew, toList, ccList, bccList, subject, attachments, latest]) + + const saveDraftNow = useCallback(async () => { + if (sentRef.current || discardedRef.current) return + if (savingRef.current) { pendingRef.current = true; return } + const payload = lastPayloadRef.current + if (!payload) return + savingRef.current = true + pendingRef.current = false + try { + payload.draftId = draftIdRef.current + const res = await window.ipc.invoke('gmail:saveDraft', payload) + if (res?.draftId && !discardedRef.current) draftIdRef.current = res.draftId + } catch { + // Autosave is best-effort; a failure just leaves the prior draft in place. + } finally { + savingRef.current = false + // Coalesce edits that landed mid-flight into one more save. + if (pendingRef.current && !sentRef.current && !discardedRef.current) { + pendingRef.current = false + void saveDraftNowRef.current?.() + } + } + }, []) + useEffect(() => { saveDraftNowRef.current = saveDraftNow }, [saveDraftNow]) + + const scheduleAutosave = useCallback(() => { + if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current) + autosaveTimer.current = window.setTimeout(() => { void saveDraftNow() }, DRAFT_AUTOSAVE_MS) + }, [saveDraftNow]) + + // Wait (briefly, capped) for any in-flight autosave to settle so send/discard + // don't race it into a duplicate or orphaned draft. + const waitForSaveIdle = useCallback(async () => { + for (let guard = 0; savingRef.current && guard < 40; guard++) { + await new Promise((r) => window.setTimeout(r, 50)) + } + }, []) + + // Autosave on body edits (typing, AI insert, undo/redo). + useEffect(() => { + if (!editor) return + const onUpdate = () => { + dirtyRef.current = true + lastPayloadRef.current = buildDraftPayload() + scheduleAutosave() + } + editor.on('update', onUpdate) + return () => { editor.off('update', onUpdate) } + }, [editor, buildDraftPayload, scheduleAutosave]) + + // Autosave on header edits (recipients, subject, attachments), skipping mount. + useEffect(() => { + if (!fieldsMounted.current) { fieldsMounted.current = true; return } + dirtyRef.current = true + lastPayloadRef.current = buildDraftPayload() + scheduleAutosave() + }, [toList, ccList, bccList, subject, attachments, buildDraftPayload, scheduleAutosave]) + + // Safety net: if the composer is torn down without an explicit close (e.g. + // navigating away), still flush unsaved edits. closedRef guards the common + // path where handleClose already saved. + useEffect(() => { + return () => { + if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current) + if (closedRef.current || sentRef.current || discardedRef.current || savingRef.current) return + if (!dirtyRef.current) return + const payload = lastPayloadRef.current + if (!payload) return + payload.draftId = draftIdRef.current + void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {}) + } + }, []) + + // Close (X / click-away): keep the draft. Flush a final save when there are + // unsaved edits. If a save is already in flight it will persist the latest + // content, so we skip here to avoid creating a duplicate. + const handleClose = useCallback(() => { + closedRef.current = true + if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current) + if (!sentRef.current && !discardedRef.current && !savingRef.current && dirtyRef.current) { + const payload = buildDraftPayload() + if (payload) { + payload.draftId = draftIdRef.current + void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {}) + } + } + onClose() + }, [buildDraftPayload, onClose]) + + // Discard: delete the autosaved draft (if any), then close. + const handleDiscard = useCallback(() => { + discardedRef.current = true + closedRef.current = true + if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current) + void (async () => { + await waitForSaveIdle() + const id = draftIdRef.current + if (id) await window.ipc.invoke('gmail:deleteDraft', { draftId: id }).catch(() => {}) + })() + onClose() + }, [onClose, waitForSaveIdle]) + const [sending, setSending] = useState(false) const sendInGmail = async () => { if (!editor || sending) return @@ -1279,7 +1461,12 @@ const ComposeBox = memo(function ComposeBox({ // Only replies stay on the thread; forwards and new emails start fresh. const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew + // Stop autosave from racing the send (it would leave an orphaned draft), and + // let any in-flight save settle so we know the draft id to clean up. + sentRef.current = true + if (autosaveTimer.current) window.clearTimeout(autosaveTimer.current) setSending(true) + await waitForSaveIdle() try { const result = await window.ipc.invoke('gmail:sendReply', { threadId: isThreaded ? thread?.threadId : undefined, @@ -1296,12 +1483,22 @@ const ComposeBox = memo(function ComposeBox({ : undefined, }) if (result.error) { + sentRef.current = false // allow autosave to resume on a failed send toast(`Send failed: ${result.error}`, 'error') return } + // Gmail only auto-cleans drafts on a threaded send; remove any draft we + // autosaved for a brand-new message so it doesn't linger after sending. + const leftover = draftIdRef.current + if (leftover) { + draftIdRef.current = undefined + void window.ipc.invoke('gmail:deleteDraft', { draftId: leftover }).catch(() => {}) + } toast('Sent.', 'success') + closedRef.current = true onClose() } catch (err) { + sentRef.current = false toast(`Send failed: ${err instanceof Error ? err.message : String(err)}`, 'error') } finally { setSending(false) @@ -1419,7 +1616,7 @@ const ComposeBox = memo(function ComposeBox({ >{preset.label} ))} - {(isNew || mode === 'forward') && ( + {(isModal || mode === 'forward') && (
Subject {editor && } -
) - if (isNew) { + if (isModal) { return ( - { if (!open) onClose() }}> + { if (!open) handleClose() }}> @@ -1569,7 +1766,7 @@ const ComposeBox = memo(function ComposeBox({ variant="ghost" size="icon-xs" className="text-muted-foreground" - onClick={onClose} + onClick={handleClose} aria-label="Close compose" > @@ -1731,6 +1928,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 @@ -1765,10 +1964,86 @@ 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) + 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 + setDrafts((prev) => prev.filter((d) => d.draftId !== id)) + 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]) + + // Closing the draft composer may have edited/sent/deleted it — refresh. + const closeDraftEditor = useCallback(() => { + setEditingDraft(null) + void loadDrafts() + }, [loadDrafts]) + useEffect(() => { let cancelled = false const check = async () => { @@ -1800,7 +2075,7 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key !== 'n' || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.isComposing) return - if (composeOpen || settingsOpen) return + if (composeOpen || settingsOpen || editingDraft) return const target = e.target as HTMLElement | null if ( target && @@ -1816,10 +2091,11 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [composeOpen, settingsOpen]) + }, [composeOpen, settingsOpen, editingDraft]) 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) @@ -1846,20 +2122,39 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = 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) } }, [updateThreadInState]) + // Toggle the read state of a whole category — server-side across the entire + // category, not just the rows currently loaded. Optimistically flips the UI. + const markSectionReadAction = useCallback(async (section: InboxSection, read: boolean) => { + setSection(section, (prev) => ({ + ...prev, + threads: prev.threads.map((t) => ({ + ...t, + unread: !read, + messages: t.messages.map((m) => ({ ...m, unread: !read })), + })), + })) + try { + const result = await window.ipc.invoke('gmail:markSectionRead', { section, read }) + if (!result.ok && result.error) toast(`Could not update read state: ${result.error}`, 'error') + } catch (err) { + toast(`Could not update read state: ${err instanceof Error ? err.message : String(err)}`, 'error') + } + }, [setSection]) + const archiveThreadAction = useCallback(async (threadId: string) => { try { const result = await window.ipc.invoke('gmail:archiveThread', { threadId }) @@ -2151,6 +2446,9 @@ 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]) + const importantHasUnread = important.threads.some((t) => t.unread) + const otherHasUnread = other.threads.some((t) => t.unread) const hasAny = important.threads.length > 0 || other.threads.length > 0 const initialLoading = !hasAny && refreshing @@ -2186,17 +2484,15 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = {formatInboxTime(latest?.date || thread.date)}
- {isUnread && ( - - )} + +
+ +
+
+ + ) + } + return (
@@ -2237,12 +2568,29 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = setQuery(event.target.value)} - placeholder="Search loaded mail" + placeholder="Search all mail" />
- + +
+
- {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 ? (
@@ -2258,9 +2642,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
Important - - {important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'} - +
+
+ Read + void markSectionReadAction('important', checked)} + aria-label="Mark all of Important read or unread" + /> +
+ + {important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'} + +
{visibleImportant.map(renderRow)} {!important.hasReachedEnd && ( @@ -2276,9 +2670,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
Everything else - - {other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'} - +
+
+ Read + void markSectionReadAction('other', checked)} + aria-label="Mark all of Everything else read or unread" + /> +
+ + {other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'} + +
{visibleOther.map(renderRow)} {!other.hasReachedEnd && ( @@ -2315,6 +2719,14 @@ 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..2a75f408 --- /dev/null +++ b/apps/x/packages/core/src/config/gmail_sync_config.ts @@ -0,0 +1,61 @@ +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))); +} + +/** + * 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 { + try { + if (fs.existsSync(CONFIG_FILE)) { + const raw = fs.readFileSync(CONFIG_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + const value = Number(parsed?.maxEmails); + if (Number.isFinite(value) && value > 0) { + return clampMaxEmails(value); + } + } + } catch (err) { + console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err); + } + 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 { + const configDir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + const config: GmailSyncConfig = { maxEmails: clampMaxEmails(maxEmails) }; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index e4d478e9..3270abd7 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) { @@ -157,6 +191,81 @@ export async function markThreadRead(threadId: string): Promise { + try { + const gmailClient = await getGmailClientOrThrow(); + + // Gather threads in this category that still need the change. + const targets: { file: string; entry: SnapshotCacheEntry; messageIds: string[] }[] = []; + if (fs.existsSync(CACHE_DIR)) { + let names: string[] = []; + try { names = fs.readdirSync(CACHE_DIR); } catch { names = []; } + for (const name of names) { + if (!name.endsWith('.json')) continue; + const file = path.join(CACHE_DIR, name); + let entry: SnapshotCacheEntry; + try { entry = JSON.parse(fs.readFileSync(file, 'utf-8')) as SnapshotCacheEntry; } catch { continue; } + const snap = entry?.snapshot; + if (!snap) continue; + const isUnread = snap.unread === true; + // Marking read: skip already-read. Marking unread: skip already-unread. + if (read ? !isUnread : isUnread) continue; + // Mirror snapshotImportance: missing importance counts as 'important'. + const importance = snap.importance === 'other' ? 'other' : 'important'; + if (importance !== section) continue; + const messageIds = snap.messages.map((m) => m.id).filter((id): id is string => Boolean(id)); + targets.push({ file, entry, messageIds }); + } + } + + if (targets.length === 0) return { ok: true, count: 0 }; + + // Apply on Gmail (batchModify caps at 1000 message ids per call). + const allMessageIds = targets.flatMap((t) => t.messageIds); + const labelChange = read ? { removeLabelIds: ['UNREAD'] } : { addLabelIds: ['UNREAD'] }; + for (let i = 0; i < allMessageIds.length; i += 1000) { + const ids = allMessageIds.slice(i, i + 1000); + if (ids.length === 0) continue; + await gmailClient.users.messages.batchModify({ + userId: 'me', + requestBody: { ids, ...labelChange }, + }); + } + + // Mirror into the local cache so the UI updates without a full resync. + for (const t of targets) { + for (const m of t.entry.snapshot.messages) m.unread = !read; + t.entry.snapshot.unread = !read; + try { + fs.writeFileSync(t.file, JSON.stringify(t.entry), 'utf-8'); + } catch (err) { + console.warn(`[Gmail cache] markSectionRead write failed:`, err); + } + } + + return { ok: true, count: targets.length }; + } catch (err) { + return { ok: false, count: 0, error: err instanceof Error ? err.message : String(err) }; + } +} + interface SyncedThread { threadId: string; markdown: string; @@ -176,6 +285,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; @@ -194,6 +305,7 @@ export interface GmailThreadSnapshot { savedPath: string; }>; messageIdHeader?: string; + isDraft?: boolean; }>; } @@ -792,6 +904,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) { @@ -855,7 +1006,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, @@ -868,26 +1019,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 { @@ -1148,24 +1279,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; @@ -1180,19 +1316,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 }); @@ -1205,7 +1346,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); @@ -1434,10 +1577,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) { @@ -1447,7 +1590,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..."); @@ -1485,6 +1628,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; @@ -1588,6 +1744,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(); @@ -1597,82 +1835,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({ @@ -1706,6 +1873,265 @@ 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 { + // The draft was likely deleted or already sent; start a new one. + res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } }); + } + } else { + res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } }); + } + + // Wake the sync loop so the local cache reflects the draft. + triggerSync(); + + 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; + + 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, + }], + }; +} + +/** + * 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..e65e2b5f 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -124,6 +124,8 @@ 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(), }); export type GmailThreadMessage = z.infer; @@ -134,6 +136,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 d1543006..79583141 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -205,6 +205,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({ @@ -235,9 +285,13 @@ 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:markSectionRead': { + req: z.object({ section: z.enum(['important', 'other']), read: z.boolean() }), + res: z.object({ ok: z.boolean(), count: z.number().int().nonnegative(), error: z.string().optional() }), + }, 'gmail:saveMessageHeight': { req: z.object({ threadId: z.string().min(1), From 063f6892a8e6dcfb5c15ccb19e288c74d040e7ac Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Wed, 1 Jul 2026 23:51:08 +0530 Subject: [PATCH 2/4] feat(email): on-demand attachment download, search clear, per-category read prefs - Download search-result attachments on demand before opening (fixes Linux where xdg-open reports success on a missing file so the old open-then- download fallback never fired) - Add a clear button to the search box that dismisses search and returns to the inbox - Remove the read toggle from the Important section - Replace the Everything else "Read" toggle with a "Mark as read" preference: turning it on marks all current and future "Everything else" mail read; turning it off only stops auto-reading future mail, leaving current threads untouched --- apps/x/apps/main/src/ipc.ts | 13 ++- apps/x/apps/renderer/src/App.css | 19 ++++ .../renderer/src/components/email-view.tsx | 102 +++++++++++------ .../core/src/config/gmail_sync_config.ts | 61 +++++++--- .../packages/core/src/knowledge/sync_gmail.ts | 104 +++++++++++++++++- apps/x/packages/shared/src/blocks.ts | 4 + apps/x/packages/shared/src/ipc.ts | 16 +++ 7 files changed, 269 insertions(+), 50 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ae732230..94674d55 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -69,7 +69,8 @@ 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, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, markSectionRead, 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, markSectionRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; +import { getAutoReadEverythingElse, setAutoReadEverythingElse } from '@x/core/dist/config/gmail_sync_config.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'; @@ -773,6 +774,16 @@ export function setupIpcHandlers() { 'gmail:markSectionRead': async (_event, args) => { return markSectionRead(args.section, args.read); }, + 'gmail:downloadAttachment': async (_event, args) => { + return downloadAttachment(args); + }, + 'gmail:getAutoReadEverythingElse': async () => { + return { enabled: getAutoReadEverythingElse() }; + }, + 'gmail:setAutoReadEverythingElse': async (_event, args) => { + setAutoReadEverythingElse(args.enabled); + return {}; + }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index bdb1430f..60f24851 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; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index f469e9bd..113d1afe 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -499,16 +499,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 ( @@ -520,7 +536,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable openAttachment(att.savedPath, att.filename)} + onClick={() => void openAttachment(att)} title={`Open ${att.filename}`} > @@ -1975,6 +1991,8 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [searching, setSearching] = useState(false) const [searchError, setSearchError] = useState(null) const searchEpoch = useRef(0) + // Persistent preference: auto-mark future "Everything else" mail as read. + const [autoReadOther, setAutoReadOther] = useState(false) // Gmail sync uses the native Google OAuth connection. const [emailConnection, setEmailConnection] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -2026,6 +2044,16 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = return () => window.clearTimeout(handle) }, [query]) + // Load the persisted "auto-read Everything else" preference on mount. + useEffect(() => { + let cancelled = false + void window.ipc + .invoke('gmail:getAutoReadEverythingElse', {}) + .then((res) => { if (!cancelled) setAutoReadOther(res.enabled) }) + .catch(() => {}) + return () => { cancelled = true } + }, []) + const deleteDraftAction = useCallback(async (thread: GmailThread) => { const id = thread.draftId if (!id) return @@ -2155,6 +2183,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } }, [setSection]) + // "Mark as read" toggle for Everything else. Turning it ON persists the + // preference AND marks every current "other" thread read; turning it OFF only + // clears the preference (future mail), leaving existing threads untouched. + const setAutoReadOtherAction = useCallback(async (enabled: boolean) => { + setAutoReadOther(enabled) + try { + await window.ipc.invoke('gmail:setAutoReadEverythingElse', { enabled }) + } catch (err) { + toast(`Could not update preference: ${err instanceof Error ? err.message : String(err)}`, 'error') + } + if (enabled) await markSectionReadAction('other', true) + }, [markSectionReadAction]) + const archiveThreadAction = useCallback(async (threadId: string) => { try { const result = await window.ipc.invoke('gmail:archiveThread', { threadId }) @@ -2447,8 +2488,6 @@ 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]) - const importantHasUnread = important.threads.some((t) => t.unread) - const otherHasUnread = other.threads.some((t) => t.unread) const hasAny = important.threads.length > 0 || other.threads.length > 0 const initialLoading = !hasAny && refreshing @@ -2570,6 +2609,17 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = onChange={(event) => setQuery(event.target.value)} placeholder="Search all mail" /> + {query && ( + + )}
@@ -2642,19 +2692,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
Important -
-
- Read - void markSectionReadAction('important', checked)} - aria-label="Mark all of Important read or unread" - /> -
- - {important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'} - -
+ + {important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'} +
{visibleImportant.map(renderRow)} {!important.hasReachedEnd && ( @@ -2671,12 +2711,12 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
Everything else
-
- Read +
+ Mark as read void markSectionReadAction('other', checked)} - aria-label="Mark all of Everything else read or unread" + checked={autoReadOther} + onCheckedChange={(checked) => void setAutoReadOtherAction(checked)} + aria-label="Mark Everything else as read now and going forward" />
diff --git a/apps/x/packages/core/src/config/gmail_sync_config.ts b/apps/x/packages/core/src/config/gmail_sync_config.ts index 2a75f408..9e8cdec1 100644 --- a/apps/x/packages/core/src/config/gmail_sync_config.ts +++ b/apps/x/packages/core/src/config/gmail_sync_config.ts @@ -20,29 +20,48 @@ const MAX_MAX_EMAILS = 5000; interface GmailSyncConfig { maxEmails: number; + /** + * When true, threads the classifier labels as "Everything else" are + * automatically marked read as they arrive during sync. Toggling this on + * only governs FUTURE mail; existing threads are marked read separately at + * toggle time (and are never touched when this is turned off). + */ + autoReadEverythingElse?: boolean; } 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 { - try { - if (fs.existsSync(CONFIG_FILE)) { - const raw = fs.readFileSync(CONFIG_FILE, 'utf-8'); - const parsed = JSON.parse(raw) as Partial; - const value = Number(parsed?.maxEmails); - if (Number.isFinite(value) && value > 0) { - return clampMaxEmails(value); - } - } - } catch (err) { - console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err); + const value = Number(readConfig()?.maxEmails); + if (Number.isFinite(value) && value > 0) { + return clampMaxEmails(value); } return DEFAULT_MAX_EMAILS; } @@ -52,10 +71,18 @@ export function getMaxEmails(): number { * clamped into the supported range before writing. */ export function setMaxEmails(maxEmails: number): void { - const configDir = path.dirname(CONFIG_FILE); - if (!fs.existsSync(configDir)) { - fs.mkdirSync(configDir, { recursive: true }); - } - const config: GmailSyncConfig = { maxEmails: clampMaxEmails(maxEmails) }; - fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) }); +} + +/** + * Whether newly-arriving "Everything else" threads should be auto-marked read + * during sync. Defaults to false (off) when unset. + */ +export function getAutoReadEverythingElse(): boolean { + return readConfig()?.autoReadEverythingElse === true; +} + +/** Persist the "auto-mark Everything else as read" preference. */ +export function setAutoReadEverythingElse(enabled: boolean): void { + writeConfig({ ...readConfig(), autoReadEverythingElse: enabled }); } diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 3270abd7..80945c5c 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -4,7 +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 { getMaxEmails, getAutoReadEverythingElse } 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'; @@ -303,6 +303,8 @@ export interface GmailThreadSnapshot { mimeType?: string; sizeBytes?: number; savedPath: string; + messageId?: string; + attachmentId?: string; }>; messageIdHeader?: string; isDraft?: boolean; @@ -458,6 +460,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; } /** @@ -494,6 +500,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, }); } } @@ -921,6 +929,23 @@ async function buildAndCacheSnapshot( console.warn(`[Gmail] classify failed for ${threadId}:`, err); } + // Auto-mark newly-classified "Everything else" mail as read when the user + // has opted in. Governs future mail only — existing threads are handled at + // toggle time via markSectionRead. + if (snapshot.importance === 'other' && snapshot.unread && getAutoReadEverythingElse()) { + try { + await gmailClient.users.threads.modify({ + userId: 'me', + id: threadId, + requestBody: { removeLabelIds: ['UNREAD'] }, + }); + for (const m of snapshot.messages) m.unread = false; + snapshot.unread = false; + } catch (err) { + console.warn(`[Gmail] auto-read (Everything else) failed for ${threadId}:`, err); + } + } + if (threadData.historyId) { writeCachedSnapshot(threadId, threadData.historyId, snapshot); } @@ -1050,6 +1075,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 { diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index e65e2b5f..050687ad 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; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 79583141..df548aeb 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -292,6 +292,22 @@ const ipcSchemas = { req: z.object({ section: z.enum(['important', 'other']), read: z.boolean() }), res: z.object({ ok: z.boolean(), count: z.number().int().nonnegative(), 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:getAutoReadEverythingElse': { + req: z.object({}), + res: z.object({ enabled: z.boolean() }), + }, + 'gmail:setAutoReadEverythingElse': { + req: z.object({ enabled: z.boolean() }), + res: z.object({}), + }, 'gmail:saveMessageHeight': { req: z.object({ threadId: z.string().min(1), From 357da440c6da32527b6f036fab518f2907fcfa9c Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Thu, 2 Jul 2026 14:31:52 +0530 Subject: [PATCH 3/4] feat(email): Superhuman-style shortcuts, smooth scrolling & fast animations List: j/k/arrow cursor with focus ring, Enter/o open, e archive, # trash, u read toggle, c/n compose, / search, g+i / g+d view switch, ? help overlay, layered Escape (suggestions > link bar > composer > thread > search). Thread: r / a / f open reply, reply-all (with fallback), forward. Composer: Cmd/Ctrl+Enter send (commits half-typed recipients), Cmd/Ctrl+ Shift+C/B reveal+focus Cc/Bcc, Esc closes with draft autosave. Perf: content-visibility row virtualization, pointer-events suppression while scrolling, memoized ThreadRow so cursor moves and page appends only re-render affected rows. Animations: 160ms row slide-out on archive/trash/draft-delete with snap-back on failure, fast thread-detail and inline-composer open transitions, 140ms ease-out animated scrolling for cursor-follow and thread-open (respects prefers-reduced-motion). Also fixes stale search-result rows on archive/read actions and Escape closing the whole compose modal while a suggestion menu or link bar was open. Co-Authored-By: Claude Fable 5 --- apps/x/apps/renderer/src/App.css | 92 +++ .../renderer/src/components/email-view.tsx | 777 +++++++++++++++--- 2 files changed, 768 insertions(+), 101 deletions(-) diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 60f24851..c8f700b3 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -224,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; @@ -338,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); } @@ -413,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 113d1afe..fbe6aac1 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -551,6 +551,19 @@ function MessageAttachments({ attachments }: { attachments: NonNullable void autoFocus?: boolean trailing?: React.ReactNode + /** Bump to move focus into this field (e.g. after a Cc/Bcc shortcut). */ + focusSignal?: number }) { const [draft, setDraft] = useState('') const [suggestions, setSuggestions] = useState([]) @@ -745,6 +761,10 @@ function RecipientField({ if (autoFocus) inputRef.current?.focus() }, [autoFocus]) + useEffect(() => { + if (focusSignal) inputRef.current?.focus() + }, [focusSignal]) + const excludeEmails = useMemo( () => value.map((token) => extractAddress(token).toLowerCase()).filter(Boolean), [value], @@ -803,6 +823,12 @@ function RecipientField({ const onKeyDown = (event: React.KeyboardEvent) => { const hasSuggestions = suggestions.length > 0 + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + // Cmd/Ctrl+Enter is "send" — commit any half-typed address on the way + // and let the event bubble to the composer's send handler. + if (draft.trim()) commit(draft) + return + } if (event.key === 'ArrowDown' && hasSuggestions) { event.preventDefault() setActiveIndex((i) => (i + 1) % suggestions.length) @@ -815,6 +841,7 @@ function RecipientField({ } if (event.key === 'Escape' && hasSuggestions) { event.preventDefault() + event.stopPropagation() setSuggestions([]) return } @@ -846,7 +873,10 @@ function RecipientField({ const showSuggestions = isFocused && suggestions.length > 0 return ( -
+
{label}
{value.map((token, index) => ( @@ -1047,6 +1077,9 @@ const ComposeBox = memo(function ComposeBox({ const [bccList, setBccList] = useState([]) const [showCc, setShowCc] = useState(initialRecipients.cc.length > 0) const [showBcc, setShowBcc] = useState(false) + // Bumped by the Cc/Bcc shortcuts so the freshly revealed field grabs focus. + const [ccFocusSignal, setCcFocusSignal] = useState(0) + const [bccFocusSignal, setBccFocusSignal] = useState(0) const [subject, setSubject] = useState(() => (thread ? composeSubject(mode, thread.subject) : '')) const modeLabel = mode === 'draft' ? 'Draft' : isNew ? 'New message' : mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : 'Reply' @@ -1067,6 +1100,10 @@ const ComposeBox = memo(function ComposeBox({ .join('') }, [mode, thread]) + // Ref so the Tiptap keydown handler (captured once at editor creation) + // always calls the latest send closure; assigned below sendInGmail. + const sendRef = useRef<() => void>(() => {}) + const editor = useEditor({ extensions: [ StarterKit.configure({ link: false }), @@ -1077,6 +1114,13 @@ const ComposeBox = memo(function ComposeBox({ ], editorProps: { attributes: { class: 'compose-content' }, + handleKeyDown: (_view, event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter' && !event.shiftKey) { + sendRef.current() + return true + } + return false + }, }, content: initialContent, }) @@ -1520,6 +1564,44 @@ const ComposeBox = memo(function ComposeBox({ setSending(false) } } + sendRef.current = () => { void sendInGmail() } + + // Composer-level shortcuts, on the wrapper so they work from any field. + // Inner handlers (recipient menu, link bar, AI Enter) preventDefault first + // and keep priority via the defaultPrevented check. + const onComposerKeyDown = (e: React.KeyboardEvent) => { + if (e.defaultPrevented) return + const mod = e.metaKey || e.ctrlKey + if (mod && e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + e.stopPropagation() + // Deferred one tick: a recipient field may have just committed a + // half-typed address, and send must read the re-rendered list. + window.setTimeout(() => sendRef.current(), 0) + return + } + if (mod && e.shiftKey && e.key.toLowerCase() === 'c') { + e.preventDefault() + e.stopPropagation() + setShowCc(true) + setCcFocusSignal((n) => n + 1) + return + } + if (mod && e.shiftKey && e.key.toLowerCase() === 'b') { + e.preventDefault() + e.stopPropagation() + setShowBcc(true) + setBccFocusSignal((n) => n + 1) + return + } + // The modal variant closes via Radix's own Escape handling. + if (!isModal && e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + if (linkOpen) cancelLink() + else handleClose() + } + } const refineWithCopilot = () => { if (!editor || !thread) return @@ -1578,8 +1660,8 @@ const ComposeBox = memo(function ComposeBox({
} /> - {showCc && } - {showBcc && } + {showCc && } + {showBcc && }
{ - if (event.key === 'Enter') { + // Plain Enter runs the AI writer; Cmd/Ctrl+Enter bubbles to send. + if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) { event.preventDefault() void runAiBar() } @@ -1753,6 +1836,19 @@ const ComposeBox = memo(function ComposeBox({ showCloseButton={false} aria-describedby={undefined} className="flex h-[min(720px,calc(100vh-4rem))] flex-col gap-0 overflow-hidden p-0 font-sans sm:max-w-[840px]" + onKeyDown={onComposerKeyDown} + onEscapeKeyDown={(event) => { + // 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} @@ -1774,7 +1870,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 { @@ -1967,6 +2304,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) @@ -1996,6 +2334,31 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = // 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) @@ -2057,14 +2420,18 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = 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]) + }, [loadDrafts, markLeaving]) // Closing the draft composer may have edited/sent/deleted it — refresh. const closeDraftEditor = useCallback(() => { @@ -2097,30 +2464,6 @@ 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 || editingDraft) 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, editingDraft]) - useEffect(() => { persistedImportant = important }, [important]) useEffect(() => { persistedOther = other }, [other]) useEffect(() => { persistedDrafts = drafts }, [drafts]) @@ -2137,6 +2480,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) => { @@ -2146,6 +2490,7 @@ 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)) }, []) @@ -2197,8 +2542,14 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = }, [markSectionReadAction]) 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) { @@ -2206,12 +2557,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) { @@ -2219,10 +2576,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) { @@ -2489,89 +2849,295 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = 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} - > - -
- - - -
-
- {isMounted && ( - setSelectedThreadId(null)} - hidden={!isSelected} - /> - )} -
+ ) } 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 ( -
-
+
+
) } From 080b8625c6be48b3051f16fedac4f7ae2db1b228 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:08:55 +0530 Subject: [PATCH 4/4] fix(email): draft lifecycle, keyboard nav, and composer layout fixes from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the "Mark as read" toggle for Everything else end-to-end (UI, IPC, config, sync auto-read, markSectionRead) — it could bulk-mark backlog and archived mail read on the server during cache-recovery syncs - Sending an edited draft now reuses the draft's stored In-Reply-To/References instead of self-referencing its own Message-ID (external clients thread correctly); standalone drafts send as fresh messages - Keep draft autosaves out of the sync pipeline: skip DRAFT history messages (no more md/knowledge-event leaks, phantom "New email" notifications, or per-autosave LLM reclassification) and mirror the draft body onto the cached snapshot surgically instead of waking the sync loop - Only recreate a Gmail draft when update fails with 404/410 — transient errors no longer pile up duplicates - Emptying a draft and closing now deletes it from Gmail (matches Gmail) - Forward keydown out of message iframes so j/k/e/r etc. keep working after clicking into an email body; restore expanded quotes across theme reloads - Composer footer wraps on narrow panes: formatting toolbar + Discard move as one unit instead of overflowing into each other Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 13 +- .../renderer/src/components/email-view.tsx | 204 ++++++++++-------- .../core/src/config/gmail_sync_config.ts | 19 -- .../packages/core/src/knowledge/sync_gmail.ts | 147 +++++-------- apps/x/packages/shared/src/blocks.ts | 6 + apps/x/packages/shared/src/ipc.ts | 12 -- 6 files changed, 168 insertions(+), 233 deletions(-) diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 94674d55..c471f990 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -69,8 +69,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, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, markSectionRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus } from '@x/core/dist/knowledge/sync_gmail.js'; -import { getAutoReadEverythingElse, setAutoReadEverythingElse } from '@x/core/dist/config/gmail_sync_config.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'; @@ -771,19 +770,9 @@ export function setupIpcHandlers() { 'gmail:markThreadRead': async (_event, args) => { return markThreadRead(args.threadId, args.read); }, - 'gmail:markSectionRead': async (_event, args) => { - return markSectionRead(args.section, args.read); - }, 'gmail:downloadAttachment': async (_event, args) => { return downloadAttachment(args); }, - 'gmail:getAutoReadEverythingElse': async () => { - return { enabled: getAutoReadEverythingElse() }; - }, - 'gmail:setAutoReadEverythingElse': async (_event, args) => { - setAutoReadEverythingElse(args.enabled); - return {}; - }, 'gmail:saveMessageHeight': async (_event, args) => { saveMessageBodyHeight(args.threadId, args.messageId, args.height); return {}; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index fbe6aac1..b2e7b4f1 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -11,7 +11,6 @@ import { useTheme } from '@/contexts/theme-context' import { SettingsDialog } from '@/components/settings-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Switch } from '@/components/ui/switch' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' type GmailThread = blocks.GmailThread @@ -198,6 +197,37 @@ 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() @@ -408,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( @@ -420,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 @@ -598,8 +642,12 @@ function ComposeToolbarButton({ } function ComposeToolbar({ editor, onOpenLink }: { editor: Editor; onOpenLink: () => 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 ( -
+
)} -
-
+ {/* 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 && } + +
) @@ -2329,8 +2401,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = const [searching, setSearching] = useState(false) const [searchError, setSearchError] = useState(null) const searchEpoch = useRef(0) - // Persistent preference: auto-mark future "Everything else" mail as read. - const [autoReadOther, setAutoReadOther] = useState(false) // Gmail sync uses the native Google OAuth connection. const [emailConnection, setEmailConnection] = useState(null) const [settingsOpen, setSettingsOpen] = useState(false) @@ -2407,16 +2477,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = return () => window.clearTimeout(handle) }, [query]) - // Load the persisted "auto-read Everything else" preference on mount. - useEffect(() => { - let cancelled = false - void window.ipc - .invoke('gmail:getAutoReadEverythingElse', {}) - .then((res) => { if (!cancelled) setAutoReadOther(res.enabled) }) - .catch(() => {}) - return () => { cancelled = true } - }, []) - const deleteDraftAction = useCallback(async (thread: GmailThread) => { const id = thread.draftId if (!id) return @@ -2509,38 +2569,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps = } }, [updateThreadInState]) - // Toggle the read state of a whole category — server-side across the entire - // category, not just the rows currently loaded. Optimistically flips the UI. - const markSectionReadAction = useCallback(async (section: InboxSection, read: boolean) => { - setSection(section, (prev) => ({ - ...prev, - threads: prev.threads.map((t) => ({ - ...t, - unread: !read, - messages: t.messages.map((m) => ({ ...m, unread: !read })), - })), - })) - try { - const result = await window.ipc.invoke('gmail:markSectionRead', { section, read }) - if (!result.ok && result.error) toast(`Could not update read state: ${result.error}`, 'error') - } catch (err) { - toast(`Could not update read state: ${err instanceof Error ? err.message : String(err)}`, 'error') - } - }, [setSection]) - - // "Mark as read" toggle for Everything else. Turning it ON persists the - // preference AND marks every current "other" thread read; turning it OFF only - // clears the preference (future mail), leaving existing threads untouched. - const setAutoReadOtherAction = useCallback(async (enabled: boolean) => { - setAutoReadOther(enabled) - try { - await window.ipc.invoke('gmail:setAutoReadEverythingElse', { enabled }) - } catch (err) { - toast(`Could not update preference: ${err instanceof Error ? err.message : String(err)}`, 'error') - } - if (enabled) await markSectionReadAction('other', true) - }, [markSectionReadAction]) - 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. @@ -3284,19 +3312,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
Everything else -
-
- Mark as read - void setAutoReadOtherAction(checked)} - aria-label="Mark Everything else as read now and going forward" - /> -
- - {other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'} - -
+ + {other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'} +
{visibleOther.map(renderRow)} {!other.hasReachedEnd && ( diff --git a/apps/x/packages/core/src/config/gmail_sync_config.ts b/apps/x/packages/core/src/config/gmail_sync_config.ts index 9e8cdec1..d59be6cf 100644 --- a/apps/x/packages/core/src/config/gmail_sync_config.ts +++ b/apps/x/packages/core/src/config/gmail_sync_config.ts @@ -20,13 +20,6 @@ const MAX_MAX_EMAILS = 5000; interface GmailSyncConfig { maxEmails: number; - /** - * When true, threads the classifier labels as "Everything else" are - * automatically marked read as they arrive during sync. Toggling this on - * only governs FUTURE mail; existing threads are marked read separately at - * toggle time (and are never touched when this is turned off). - */ - autoReadEverythingElse?: boolean; } function clampMaxEmails(value: number): number { @@ -74,15 +67,3 @@ export function setMaxEmails(maxEmails: number): void { writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) }); } -/** - * Whether newly-arriving "Everything else" threads should be auto-marked read - * during sync. Defaults to false (off) when unset. - */ -export function getAutoReadEverythingElse(): boolean { - return readConfig()?.autoReadEverythingElse === true; -} - -/** Persist the "auto-mark Everything else as read" preference. */ -export function setAutoReadEverythingElse(enabled: boolean): void { - writeConfig({ ...readConfig(), autoReadEverythingElse: enabled }); -} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 80945c5c..83011e50 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -4,7 +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, getAutoReadEverythingElse } from '../config/gmail_sync_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'; @@ -191,81 +191,6 @@ export async function markThreadRead(threadId: string, read: boolean = true): Pr } } -export interface MarkSectionReadResult { - ok: boolean; - count: number; - error?: string; -} - -/** - * Toggle the read state of every thread in a category ('important' or 'other') - * on the server. The category is our local AI classification, so the set is read - * from the inbox cache; the change is applied on Gmail via a chunked - * messages.batchModify (UNREAD removed when read=true, added when read=false), - * then mirrored into the local cache. Only threads that actually need the change - * are touched. - */ -export async function markSectionRead( - section: 'important' | 'other', - read: boolean = true, -): Promise { - try { - const gmailClient = await getGmailClientOrThrow(); - - // Gather threads in this category that still need the change. - const targets: { file: string; entry: SnapshotCacheEntry; messageIds: string[] }[] = []; - if (fs.existsSync(CACHE_DIR)) { - let names: string[] = []; - try { names = fs.readdirSync(CACHE_DIR); } catch { names = []; } - for (const name of names) { - if (!name.endsWith('.json')) continue; - const file = path.join(CACHE_DIR, name); - let entry: SnapshotCacheEntry; - try { entry = JSON.parse(fs.readFileSync(file, 'utf-8')) as SnapshotCacheEntry; } catch { continue; } - const snap = entry?.snapshot; - if (!snap) continue; - const isUnread = snap.unread === true; - // Marking read: skip already-read. Marking unread: skip already-unread. - if (read ? !isUnread : isUnread) continue; - // Mirror snapshotImportance: missing importance counts as 'important'. - const importance = snap.importance === 'other' ? 'other' : 'important'; - if (importance !== section) continue; - const messageIds = snap.messages.map((m) => m.id).filter((id): id is string => Boolean(id)); - targets.push({ file, entry, messageIds }); - } - } - - if (targets.length === 0) return { ok: true, count: 0 }; - - // Apply on Gmail (batchModify caps at 1000 message ids per call). - const allMessageIds = targets.flatMap((t) => t.messageIds); - const labelChange = read ? { removeLabelIds: ['UNREAD'] } : { addLabelIds: ['UNREAD'] }; - for (let i = 0; i < allMessageIds.length; i += 1000) { - const ids = allMessageIds.slice(i, i + 1000); - if (ids.length === 0) continue; - await gmailClient.users.messages.batchModify({ - userId: 'me', - requestBody: { ids, ...labelChange }, - }); - } - - // Mirror into the local cache so the UI updates without a full resync. - for (const t of targets) { - for (const m of t.entry.snapshot.messages) m.unread = !read; - t.entry.snapshot.unread = !read; - try { - fs.writeFileSync(t.file, JSON.stringify(t.entry), 'utf-8'); - } catch (err) { - console.warn(`[Gmail cache] markSectionRead write failed:`, err); - } - } - - return { ok: true, count: targets.length }; - } catch (err) { - return { ok: false, count: 0, error: err instanceof Error ? err.message : String(err) }; - } -} - interface SyncedThread { threadId: string; markdown: string; @@ -308,6 +233,14 @@ export interface GmailThreadSnapshot { }>; 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; }>; } @@ -929,23 +862,6 @@ async function buildAndCacheSnapshot( console.warn(`[Gmail] classify failed for ${threadId}:`, err); } - // Auto-mark newly-classified "Everything else" mail as read when the user - // has opted in. Governs future mail only — existing threads are handled at - // toggle time via markSectionRead. - if (snapshot.importance === 'other' && snapshot.unread && getAutoReadEverythingElse()) { - try { - await gmailClient.users.threads.modify({ - userId: 'me', - id: threadId, - requestBody: { removeLabelIds: ['UNREAD'] }, - }); - for (const m of snapshot.messages) m.unread = false; - snapshot.unread = false; - } catch (err) { - console.warn(`[Gmail] auto-read (Everything else) failed for ${threadId}:`, err); - } - } - if (threadData.historyId) { writeCachedSnapshot(threadId, threadData.historyId, snapshot); } @@ -1562,6 +1478,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); } @@ -2023,16 +1947,37 @@ export async function saveThreadDraft(opts: SaveDraftOptions): Promise; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index df548aeb..ba9ad7a1 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -288,10 +288,6 @@ const ipcSchemas = { req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }), res: z.object({ ok: z.boolean(), error: z.string().optional() }), }, - 'gmail:markSectionRead': { - req: z.object({ section: z.enum(['important', 'other']), read: z.boolean() }), - res: z.object({ ok: z.boolean(), count: z.number().int().nonnegative(), error: z.string().optional() }), - }, 'gmail:downloadAttachment': { req: z.object({ messageId: z.string().min(1), @@ -300,14 +296,6 @@ const ipcSchemas = { }), res: z.object({ ok: z.boolean(), error: z.string().optional() }), }, - 'gmail:getAutoReadEverythingElse': { - req: z.object({}), - res: z.object({ enabled: z.boolean() }), - }, - 'gmail:setAutoReadEverythingElse': { - req: z.object({ enabled: z.boolean() }), - res: z.object({}), - }, 'gmail:saveMessageHeight': { req: z.object({ threadId: z.string().min(1),