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:
Arjun 2026-07-03 01:08:55 +05:30
parent 357da440c6
commit 080b8625c6
6 changed files with 168 additions and 233 deletions

View file

@ -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 {};

View file

@ -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 && (

View file

@ -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 });
}

View file

@ -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,
}],
};
}

View file

@ -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>;

View file

@ -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),