mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
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
This commit is contained in:
parent
ab9bce6203
commit
063f6892a8
7 changed files with 269 additions and 50 deletions
|
|
@ -69,7 +69,8 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
||||||
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
||||||
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
||||||
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.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 { 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 { 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';
|
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) => {
|
'gmail:markSectionRead': async (_event, args) => {
|
||||||
return markSectionRead(args.section, args.read);
|
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) => {
|
'gmail:saveMessageHeight': async (_event, args) => {
|
||||||
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
||||||
return {};
|
return {};
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,25 @@
|
||||||
color: var(--gm-placeholder);
|
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 {
|
.gmail-icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -499,16 +499,32 @@ function formatAttachmentSize(bytes?: number): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThreadMessage['attachments']> }) {
|
function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThreadMessage['attachments']> }) {
|
||||||
const openAttachment = (path: string, filename: string) => {
|
const openAttachment = async (att: NonNullable<GmailThreadMessage['attachments']>[number]) => {
|
||||||
void window.ipc
|
try {
|
||||||
.invoke('shell:openPath', { path })
|
// Ensure the file is on disk before handing off to the OS opener. Inbox
|
||||||
.then((result) => {
|
// attachments are saved during sync, but search-result attachments are
|
||||||
if (result?.error) toast(`Could not open ${filename}: ${result.error}`, 'error')
|
// only fetched on demand. gmail:downloadAttachment short-circuits when the
|
||||||
})
|
// file already exists, so calling it first is cheap and guarantees the
|
||||||
.catch((err) => {
|
// file is present — we can't rely on shell:openPath reporting a missing
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
// file as an error (xdg-open on Linux reports success even when the path
|
||||||
toast(`Could not open ${filename}: ${message}`, 'error')
|
// 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 (
|
return (
|
||||||
|
|
@ -520,7 +536,7 @@ function MessageAttachments({ attachments }: { attachments: NonNullable<GmailThr
|
||||||
key={att.savedPath}
|
key={att.savedPath}
|
||||||
type="button"
|
type="button"
|
||||||
className="gmail-attachment"
|
className="gmail-attachment"
|
||||||
onClick={() => openAttachment(att.savedPath, att.filename)}
|
onClick={() => void openAttachment(att)}
|
||||||
title={`Open ${att.filename}`}
|
title={`Open ${att.filename}`}
|
||||||
>
|
>
|
||||||
<Paperclip size={13} />
|
<Paperclip size={13} />
|
||||||
|
|
@ -1975,6 +1991,8 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const [searchError, setSearchError] = useState<string | null>(null)
|
const [searchError, setSearchError] = useState<string | null>(null)
|
||||||
const searchEpoch = useRef(0)
|
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.
|
// Gmail sync uses the native Google OAuth connection.
|
||||||
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
@ -2026,6 +2044,16 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
return () => window.clearTimeout(handle)
|
return () => window.clearTimeout(handle)
|
||||||
}, [query])
|
}, [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 deleteDraftAction = useCallback(async (thread: GmailThread) => {
|
||||||
const id = thread.draftId
|
const id = thread.draftId
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
@ -2155,6 +2183,19 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
}
|
}
|
||||||
}, [setSection])
|
}, [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) => {
|
const archiveThreadAction = useCallback(async (threadId: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('gmail:archiveThread', { threadId })
|
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 visibleImportant = useMemo(() => filterThreads(important.threads), [important.threads, filterThreads])
|
||||||
const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads])
|
const visibleOther = useMemo(() => filterThreads(other.threads), [other.threads, filterThreads])
|
||||||
const visibleDrafts = useMemo(() => filterThreads(drafts), [drafts, 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 hasAny = important.threads.length > 0 || other.threads.length > 0
|
||||||
const initialLoading = !hasAny && refreshing
|
const initialLoading = !hasAny && refreshing
|
||||||
|
|
@ -2570,6 +2609,17 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder="Search all mail"
|
placeholder="Search all mail"
|
||||||
/>
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="gmail-search-clear"
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
title="Clear search"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="gmail-topbar-actions">
|
<div className="gmail-topbar-actions">
|
||||||
<div className="flex items-center rounded-md border border-border p-0.5 text-xs font-medium">
|
<div className="flex items-center rounded-md border border-border p-0.5 text-xs font-medium">
|
||||||
|
|
@ -2642,19 +2692,9 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
<section className="gmail-section">
|
<section className="gmail-section">
|
||||||
<div className="gmail-list-header">
|
<div className="gmail-list-header">
|
||||||
<span>Important</span>
|
<span>Important</span>
|
||||||
<div className="flex items-center gap-2.5">
|
<span>
|
||||||
<div className="flex items-center gap-1.5" title={importantHasUnread ? 'Mark all in Important as read' : 'Mark all in Important as unread'}>
|
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
|
||||||
<span className="text-xs text-muted-foreground">Read</span>
|
</span>
|
||||||
<Switch
|
|
||||||
checked={!importantHasUnread}
|
|
||||||
onCheckedChange={(checked) => void markSectionReadAction('important', checked)}
|
|
||||||
aria-label="Mark all of Important read or unread"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{visibleImportant.map(renderRow)}
|
{visibleImportant.map(renderRow)}
|
||||||
{!important.hasReachedEnd && (
|
{!important.hasReachedEnd && (
|
||||||
|
|
@ -2671,12 +2711,12 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
<div className="gmail-list-header">
|
<div className="gmail-list-header">
|
||||||
<span>Everything else</span>
|
<span>Everything else</span>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex items-center gap-1.5" title={otherHasUnread ? 'Mark all in Everything else as read' : 'Mark all in Everything else as unread'}>
|
<div className="flex items-center gap-1.5" title="Mark Everything else as read — applies to these and future emails">
|
||||||
<span className="text-xs text-muted-foreground">Read</span>
|
<span className="text-xs text-muted-foreground">Mark as read</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={!otherHasUnread}
|
checked={autoReadOther}
|
||||||
onCheckedChange={(checked) => void markSectionReadAction('other', checked)}
|
onCheckedChange={(checked) => void setAutoReadOtherAction(checked)}
|
||||||
aria-label="Mark all of Everything else read or unread"
|
aria-label="Mark Everything else as read now and going forward"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
|
|
|
||||||
|
|
@ -20,29 +20,48 @@ const MAX_MAX_EMAILS = 5000;
|
||||||
|
|
||||||
interface GmailSyncConfig {
|
interface GmailSyncConfig {
|
||||||
maxEmails: number;
|
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 {
|
function clampMaxEmails(value: number): number {
|
||||||
return Math.max(MIN_MAX_EMAILS, Math.min(MAX_MAX_EMAILS, Math.floor(value)));
|
return Math.max(MIN_MAX_EMAILS, Math.min(MAX_MAX_EMAILS, Math.floor(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readConfig(): Partial<GmailSyncConfig> {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_FILE)) {
|
||||||
|
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||||
|
return JSON.parse(raw) as Partial<GmailSyncConfig>;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err);
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(config: Partial<GmailSyncConfig>): 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.
|
* Read the configured max email count for the onboarding/full sync.
|
||||||
* Falls back to {@link DEFAULT_MAX_EMAILS} when the file is missing, malformed,
|
* Falls back to {@link DEFAULT_MAX_EMAILS} when the file is missing, malformed,
|
||||||
* or holds an out-of-range value.
|
* or holds an out-of-range value.
|
||||||
*/
|
*/
|
||||||
export function getMaxEmails(): number {
|
export function getMaxEmails(): number {
|
||||||
try {
|
const value = Number(readConfig()?.maxEmails);
|
||||||
if (fs.existsSync(CONFIG_FILE)) {
|
if (Number.isFinite(value) && value > 0) {
|
||||||
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
return clampMaxEmails(value);
|
||||||
const parsed = JSON.parse(raw) as Partial<GmailSyncConfig>;
|
|
||||||
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;
|
return DEFAULT_MAX_EMAILS;
|
||||||
}
|
}
|
||||||
|
|
@ -52,10 +71,18 @@ export function getMaxEmails(): number {
|
||||||
* clamped into the supported range before writing.
|
* clamped into the supported range before writing.
|
||||||
*/
|
*/
|
||||||
export function setMaxEmails(maxEmails: number): void {
|
export function setMaxEmails(maxEmails: number): void {
|
||||||
const configDir = path.dirname(CONFIG_FILE);
|
writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) });
|
||||||
if (!fs.existsSync(configDir)) {
|
}
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
|
||||||
}
|
/**
|
||||||
const config: GmailSyncConfig = { maxEmails: clampMaxEmails(maxEmails) };
|
* Whether newly-arriving "Everything else" threads should be auto-marked read
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
* 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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { google, gmail_v1 as gmail } from 'googleapis';
|
||||||
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
import { NodeHtmlMarkdown } from 'node-html-markdown'
|
||||||
import { OAuth2Client } from 'google-auth-library';
|
import { OAuth2Client } from 'google-auth-library';
|
||||||
import { WorkDir } from '../config/config.js';
|
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 { GoogleClientFactory } from './google-client-factory.js';
|
||||||
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
|
||||||
import { limitEventItems } from './limit_event_items.js';
|
import { limitEventItems } from './limit_event_items.js';
|
||||||
|
|
@ -303,6 +303,8 @@ export interface GmailThreadSnapshot {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
savedPath: string;
|
savedPath: string;
|
||||||
|
messageId?: string;
|
||||||
|
attachmentId?: string;
|
||||||
}>;
|
}>;
|
||||||
messageIdHeader?: string;
|
messageIdHeader?: string;
|
||||||
isDraft?: boolean;
|
isDraft?: boolean;
|
||||||
|
|
@ -458,6 +460,10 @@ interface ExtractedAttachment {
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
savedPath: string;
|
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,
|
mimeType: part.mimeType ?? undefined,
|
||||||
sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined,
|
sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined,
|
||||||
savedPath: `gmail_sync/attachments/${safeName}`,
|
savedPath: `gmail_sync/attachments/${safeName}`,
|
||||||
|
messageId: msgId,
|
||||||
|
attachmentId: attId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -921,6 +929,23 @@ async function buildAndCacheSnapshot(
|
||||||
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
|
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) {
|
if (threadData.historyId) {
|
||||||
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
|
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
|
||||||
}
|
}
|
||||||
|
|
@ -1050,6 +1075,83 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string,
|
||||||
return null;
|
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<DownloadAttachmentResult> {
|
||||||
|
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<string | null> => {
|
||||||
|
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 ---
|
// --- Sync Logic ---
|
||||||
|
|
||||||
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise<SyncedThread | null> {
|
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise<SyncedThread | null> {
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,10 @@ export const GmailAttachmentSchema = z.object({
|
||||||
mimeType: z.string().optional(),
|
mimeType: z.string().optional(),
|
||||||
sizeBytes: z.number().int().nonnegative().optional(),
|
sizeBytes: z.number().int().nonnegative().optional(),
|
||||||
savedPath: z.string(),
|
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<typeof GmailAttachmentSchema>;
|
export type GmailAttachment = z.infer<typeof GmailAttachmentSchema>;
|
||||||
|
|
|
||||||
|
|
@ -292,6 +292,22 @@ const ipcSchemas = {
|
||||||
req: z.object({ section: z.enum(['important', 'other']), read: z.boolean() }),
|
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() }),
|
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': {
|
'gmail:saveMessageHeight': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
threadId: z.string().min(1),
|
threadId: z.string().min(1),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue