mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix(email): draft lifecycle, keyboard nav, and composer layout fixes from review
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
357da440c6
commit
080b8625c6
6 changed files with 168 additions and 233 deletions
|
|
@ -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 {};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-0.5 border-l border-border pl-2.5">
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-l border-border pl-2.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -771,12 +819,13 @@ function RecipientField({
|
|||
)
|
||||
|
||||
// Debounced contact search — only runs when the user has actually typed
|
||||
// something. An empty draft (including the post-pick reset) closes the menu.
|
||||
// something. An emptied draft closes the menu via the onChange handler (and
|
||||
// commit() clears it after a pick); here we just invalidate in-flight
|
||||
// queries so a stale response can't reopen it.
|
||||
useEffect(() => {
|
||||
const trimmed = draft.trim()
|
||||
if (!isFocused || !trimmed) {
|
||||
queryTokenRef.current++
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const token = ++queryTokenRef.current
|
||||
|
|
@ -901,7 +950,11 @@ function RecipientField({
|
|||
ref={inputRef}
|
||||
className="h-6 min-w-[80px] flex-1 border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value
|
||||
setDraft(next)
|
||||
if (!next.trim()) setSuggestions([])
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
|
|
@ -1066,7 +1119,6 @@ const ComposeBox = memo(function ComposeBox({
|
|||
const isNew = mode === 'new'
|
||||
// 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],
|
||||
|
|
@ -1372,38 +1424,44 @@ const ComposeBox = memo(function ComposeBox({
|
|||
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
|
||||
const { threadId, inReplyTo, references } = threadingHeaders(mode, thread)
|
||||
return {
|
||||
draftId: draftIdRef.current,
|
||||
threadId: isThreaded ? thread?.threadId : undefined,
|
||||
threadId,
|
||||
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,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments: attachments.length
|
||||
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
||||
: undefined,
|
||||
}
|
||||
}, [editor, thread, mode, isNew, toList, ccList, bccList, subject, attachments, latest])
|
||||
}, [editor, thread, mode, toList, ccList, bccList, subject, attachments])
|
||||
|
||||
const saveDraftNow = useCallback(async () => {
|
||||
if (sentRef.current || discardedRef.current) return
|
||||
if (savingRef.current) { pendingRef.current = true; return }
|
||||
const payload = lastPayloadRef.current
|
||||
if (!payload) return
|
||||
if (!payload && !draftIdRef.current) return
|
||||
savingRef.current = true
|
||||
pendingRef.current = false
|
||||
try {
|
||||
if (!payload) {
|
||||
// The composer was deliberately emptied after edits — mirror Gmail and
|
||||
// delete the autosaved draft rather than leaving its stale content.
|
||||
const id = draftIdRef.current
|
||||
if (id && dirtyRef.current) {
|
||||
await window.ipc.invoke('gmail:deleteDraft', { draftId: id })
|
||||
// Only forget the id once the delete succeeded (404/410 count as
|
||||
// success server-side); a thrown failure keeps it for a retry.
|
||||
if (draftIdRef.current === id) draftIdRef.current = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.draftId = draftIdRef.current
|
||||
const res = await window.ipc.invoke('gmail:saveDraft', payload)
|
||||
if (res?.draftId && !discardedRef.current) draftIdRef.current = res.draftId
|
||||
|
|
@ -1462,7 +1520,13 @@ const ComposeBox = memo(function ComposeBox({
|
|||
if (closedRef.current || sentRef.current || discardedRef.current || savingRef.current) return
|
||||
if (!dirtyRef.current) return
|
||||
const payload = lastPayloadRef.current
|
||||
if (!payload) return
|
||||
if (!payload) {
|
||||
// Edited down to empty, then torn down — drop the stale draft.
|
||||
if (draftIdRef.current) {
|
||||
void window.ipc.invoke('gmail:deleteDraft', { draftId: draftIdRef.current }).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
payload.draftId = draftIdRef.current
|
||||
void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {})
|
||||
}
|
||||
|
|
@ -1479,6 +1543,10 @@ const ComposeBox = memo(function ComposeBox({
|
|||
if (payload) {
|
||||
payload.draftId = draftIdRef.current
|
||||
void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {})
|
||||
} else if (draftIdRef.current) {
|
||||
// Closed with the body edited down to empty — mirror Gmail and drop
|
||||
// the autosaved draft instead of keeping its stale content.
|
||||
void window.ipc.invoke('gmail:deleteDraft', { draftId: draftIdRef.current }).catch(() => {})
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
|
|
@ -1512,14 +1580,7 @@ const ComposeBox = memo(function ComposeBox({
|
|||
return
|
||||
}
|
||||
|
||||
// Build References chain from all known message ids (newest last).
|
||||
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
|
||||
const { threadId, inReplyTo, references } = threadingHeaders(mode, thread)
|
||||
|
||||
// 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.
|
||||
|
|
@ -1529,15 +1590,15 @@ const ComposeBox = memo(function ComposeBox({
|
|||
await waitForSaveIdle()
|
||||
try {
|
||||
const result = await window.ipc.invoke('gmail:sendReply', {
|
||||
threadId: isThreaded ? thread?.threadId : undefined,
|
||||
threadId,
|
||||
to: toList.join(', '),
|
||||
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'),
|
||||
bodyHtml: html,
|
||||
bodyText: text,
|
||||
inReplyTo: isThreaded ? inReplyTo : undefined,
|
||||
references: isThreaded ? references || undefined : undefined,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments: attachments.length
|
||||
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
||||
: undefined,
|
||||
|
|
@ -1785,8 +1846,11 @@ const ComposeBox = memo(function ComposeBox({
|
|||
<Button type="button" variant="outline" size="xs" onClick={cancelLink}>Cancel</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 border-t border-border px-3 py-2.5">
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* 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. */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-border px-3 py-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
|
@ -1821,10 +1885,18 @@ const ComposeBox = memo(function ComposeBox({
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
||||
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={handleDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
{/* 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. */}
|
||||
<div className="flex flex-auto flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
||||
<Button type="button" variant="ghost" size="sm" className="ml-auto text-muted-foreground" onClick={handleDiscard}>
|
||||
Discard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
@ -2329,8 +2401,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
|||
const [searching, setSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(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<GmailConnectionStatus | null>(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 =
|
|||
<section className="gmail-section">
|
||||
<div className="gmail-list-header">
|
||||
<span>Everything else</span>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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">Mark as read</span>
|
||||
<Switch
|
||||
checked={autoReadOther}
|
||||
onCheckedChange={(checked) => void setAutoReadOtherAction(checked)}
|
||||
aria-label="Mark Everything else as read now and going forward"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<span>
|
||||
{other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{visibleOther.map(renderRow)}
|
||||
{!other.hasReachedEnd && (
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MarkSectionReadResult> {
|
||||
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<SaveDraft
|
|||
id: draftId,
|
||||
requestBody: { message },
|
||||
});
|
||||
} catch {
|
||||
// The draft was likely deleted or already sent; start a new one.
|
||||
} catch (err) {
|
||||
const code = (err as { code?: number })?.code
|
||||
?? (err as { response?: { status?: number } })?.response?.status;
|
||||
// Recreate only when the draft is actually gone (deleted or
|
||||
// already sent). A transient failure (timeout, 5xx) must NOT
|
||||
// fall back to create — the original draft still exists, so
|
||||
// that would silently pile up duplicates in Gmail.
|
||||
if (code !== 404 && code !== 410) throw err;
|
||||
res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } });
|
||||
}
|
||||
} else {
|
||||
res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } });
|
||||
}
|
||||
|
||||
// Wake the sync loop so the local cache reflects the draft.
|
||||
triggerSync();
|
||||
// Mirror the draft body onto the thread's cached snapshot so reopening
|
||||
// the reply composer shows the autosaved text. Surgical, like
|
||||
// markThreadRead — draft messages are filtered out of the history sync
|
||||
// (see partialSync), so no sync pass will refresh this, and waking the
|
||||
// whole sync loop per autosave (md/event writes + LLM reclassification)
|
||||
// is exactly what we're avoiding.
|
||||
if (opts.threadId) {
|
||||
const cached = readCachedSnapshot(opts.threadId);
|
||||
if (cached) {
|
||||
cached.snapshot.gmail_draft = opts.bodyText?.trim() || undefined;
|
||||
try {
|
||||
fs.writeFileSync(cachePath(opts.threadId), JSON.stringify(cached), 'utf-8');
|
||||
} catch (err) {
|
||||
console.warn(`[Gmail cache] draft write failed for ${opts.threadId}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { draftId: res.data.id || undefined };
|
||||
} catch (err) {
|
||||
|
|
@ -2091,6 +2036,12 @@ async function buildDraftSnapshot(
|
|||
const threadId = msg.threadId || draftId;
|
||||
const messageIdHeader =
|
||||
headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined;
|
||||
// The reply chain the draft already carries. The composer must reuse these
|
||||
// on send — this pseudo-thread has no other messages to rebuild them from,
|
||||
// and deriving them from the draft itself would self-reference a
|
||||
// Message-ID that never gets delivered (breaking recipients' threading).
|
||||
const inReplyToHeader = headerValue(headers, 'In-Reply-To') || undefined;
|
||||
const referencesHeader = headerValue(headers, 'References') || undefined;
|
||||
|
||||
return {
|
||||
threadId,
|
||||
|
|
@ -2114,6 +2065,8 @@ async function buildDraftSnapshot(
|
|||
bodyHtml: parts.html || undefined,
|
||||
messageIdHeader,
|
||||
isDraft: true,
|
||||
inReplyToHeader,
|
||||
referencesHeader,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,12 @@ export const GmailThreadMessageSchema = z.object({
|
|||
messageIdHeader: z.string().optional(),
|
||||
// Set on the unsent draft message within a thread (used by the Drafts view).
|
||||
isDraft: z.boolean().optional(),
|
||||
// The draft's own stored In-Reply-To / References headers. Only set on
|
||||
// draft messages: a Drafts-view pseudo-thread contains just the draft, so
|
||||
// the composer can't rebuild the reply chain from thread messages and must
|
||||
// reuse what the draft already carries.
|
||||
inReplyToHeader: z.string().optional(),
|
||||
referencesHeader: z.string().optional(),
|
||||
});
|
||||
|
||||
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue