From 063f6892a8e6dcfb5c15ccb19e288c74d040e7ac Mon Sep 17 00:00:00 2001 From: hrsvrn Date: Wed, 1 Jul 2026 23:51:08 +0530 Subject: [PATCH] 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),