mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-18 21:21:11 +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 { 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, downloadAttachment, 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, 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';
|
||||||
|
|
@ -771,19 +770,9 @@ export function setupIpcHandlers() {
|
||||||
'gmail:markThreadRead': async (_event, args) => {
|
'gmail:markThreadRead': async (_event, args) => {
|
||||||
return markThreadRead(args.threadId, args.read);
|
return markThreadRead(args.threadId, args.read);
|
||||||
},
|
},
|
||||||
'gmail:markSectionRead': async (_event, args) => {
|
|
||||||
return markSectionRead(args.section, args.read);
|
|
||||||
},
|
|
||||||
'gmail:downloadAttachment': async (_event, args) => {
|
'gmail:downloadAttachment': async (_event, args) => {
|
||||||
return downloadAttachment(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 {};
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { useTheme } from '@/contexts/theme-context'
|
||||||
import { SettingsDialog } from '@/components/settings-dialog'
|
import { SettingsDialog } from '@/components/settings-dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
|
||||||
type GmailThread = blocks.GmailThread
|
type GmailThread = blocks.GmailThread
|
||||||
|
|
@ -198,6 +197,37 @@ function buildRecipients(
|
||||||
return { to, cc }
|
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.
|
// Subject line for a reply ("Re: …") or forward ("Fwd: …"), avoiding double prefixes.
|
||||||
function composeSubject(mode: ComposeMode, rawSubject?: string): string {
|
function composeSubject(mode: ComposeMode, rawSubject?: string): string {
|
||||||
const raw = (rawSubject || '').trim()
|
const raw = (rawSubject || '').trim()
|
||||||
|
|
@ -408,6 +438,10 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t
|
||||||
const [height, setHeight] = useState(message.bodyHeight ?? 80)
|
const [height, setHeight] = useState(message.bodyHeight ?? 80)
|
||||||
const [hasQuote, setHasQuote] = useState(false)
|
const [hasQuote, setHasQuote] = useState(false)
|
||||||
const [showQuotes, setShowQuotes] = 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 adaptToTheme = useMemo(() => !isStyledHtml(message.bodyHtml!), [message.bodyHtml])
|
||||||
const srcDoc = useMemo(
|
const srcDoc = useMemo(
|
||||||
|
|
@ -420,6 +454,16 @@ function HtmlMessageBody({ message, threadId }: { message: GmailThreadMessage; t
|
||||||
const doc = iframe?.contentDocument
|
const doc = iframe?.contentDocument
|
||||||
if (!doc?.body) return
|
if (!doc?.body) return
|
||||||
setHasQuote(!!doc.querySelector('.gmail_quote, .gmail_attr, blockquote[type="cite"]'))
|
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 = () => {
|
const measure = () => {
|
||||||
// Measure off body only. documentElement.scrollHeight stretches to fill
|
// Measure off body only. documentElement.scrollHeight stretches to fill
|
||||||
// the iframe viewport, so once we size the iframe up (e.g. user expanded
|
// 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 }) {
|
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 (
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -771,12 +819,13 @@ function RecipientField({
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debounced contact search — only runs when the user has actually typed
|
// 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(() => {
|
useEffect(() => {
|
||||||
const trimmed = draft.trim()
|
const trimmed = draft.trim()
|
||||||
if (!isFocused || !trimmed) {
|
if (!isFocused || !trimmed) {
|
||||||
queryTokenRef.current++
|
queryTokenRef.current++
|
||||||
setSuggestions([])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const token = ++queryTokenRef.current
|
const token = ++queryTokenRef.current
|
||||||
|
|
@ -901,7 +950,11 @@ function RecipientField({
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="h-6 min-w-[80px] flex-1 border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
className="h-6 min-w-[80px] flex-1 border-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => {
|
||||||
|
const next = event.target.value
|
||||||
|
setDraft(next)
|
||||||
|
if (!next.trim()) setSuggestions([])
|
||||||
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
|
|
@ -1066,7 +1119,6 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
const isNew = mode === 'new'
|
const isNew = mode === 'new'
|
||||||
// Drafts and new messages share the full-modal layout (subject line, AI bar).
|
// Drafts and new messages share the full-modal layout (subject line, AI bar).
|
||||||
const isModal = isNew || mode === 'draft'
|
const isModal = isNew || mode === 'draft'
|
||||||
const latest = useMemo(() => (thread ? latestMessage(thread) : undefined), [thread])
|
|
||||||
const initialRecipients = useMemo(
|
const initialRecipients = useMemo(
|
||||||
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
|
() => (thread ? buildRecipients(mode, thread, selfEmail) : { to: [], cc: [] }),
|
||||||
[mode, thread, selfEmail],
|
[mode, thread, selfEmail],
|
||||||
|
|
@ -1372,38 +1424,44 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
const text = editor.getText().trim()
|
const text = editor.getText().trim()
|
||||||
if (!text) return null // skip empty/whitespace drafts
|
if (!text) return null // skip empty/whitespace drafts
|
||||||
const html = editor.getHTML()
|
const html = editor.getHTML()
|
||||||
const messageIds = (thread?.messages ?? [])
|
const { threadId, inReplyTo, references } = threadingHeaders(mode, thread)
|
||||||
.map((m) => m.messageIdHeader)
|
|
||||||
.filter((v): v is string => Boolean(v))
|
|
||||||
const references = messageIds.join(' ')
|
|
||||||
const inReplyTo = latest?.messageIdHeader
|
|
||||||
// Only replies stay on the thread; forwards and new emails start fresh.
|
|
||||||
const isThreaded = Boolean(thread) && mode !== 'forward' && !isNew
|
|
||||||
return {
|
return {
|
||||||
draftId: draftIdRef.current,
|
draftId: draftIdRef.current,
|
||||||
threadId: isThreaded ? thread?.threadId : undefined,
|
threadId,
|
||||||
to: toList.join(', '),
|
to: toList.join(', '),
|
||||||
cc: ccList.length ? ccList.join(', ') : undefined,
|
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||||
bcc: bccList.length ? bccList.join(', ') : undefined,
|
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||||
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : ''),
|
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : ''),
|
||||||
bodyHtml: html,
|
bodyHtml: html,
|
||||||
bodyText: text,
|
bodyText: text,
|
||||||
inReplyTo: isThreaded ? inReplyTo : undefined,
|
inReplyTo,
|
||||||
references: isThreaded ? references || undefined : undefined,
|
references,
|
||||||
attachments: attachments.length
|
attachments: attachments.length
|
||||||
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
}, [editor, thread, mode, isNew, toList, ccList, bccList, subject, attachments, latest])
|
}, [editor, thread, mode, toList, ccList, bccList, subject, attachments])
|
||||||
|
|
||||||
const saveDraftNow = useCallback(async () => {
|
const saveDraftNow = useCallback(async () => {
|
||||||
if (sentRef.current || discardedRef.current) return
|
if (sentRef.current || discardedRef.current) return
|
||||||
if (savingRef.current) { pendingRef.current = true; return }
|
if (savingRef.current) { pendingRef.current = true; return }
|
||||||
const payload = lastPayloadRef.current
|
const payload = lastPayloadRef.current
|
||||||
if (!payload) return
|
if (!payload && !draftIdRef.current) return
|
||||||
savingRef.current = true
|
savingRef.current = true
|
||||||
pendingRef.current = false
|
pendingRef.current = false
|
||||||
try {
|
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
|
payload.draftId = draftIdRef.current
|
||||||
const res = await window.ipc.invoke('gmail:saveDraft', payload)
|
const res = await window.ipc.invoke('gmail:saveDraft', payload)
|
||||||
if (res?.draftId && !discardedRef.current) draftIdRef.current = res.draftId
|
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 (closedRef.current || sentRef.current || discardedRef.current || savingRef.current) return
|
||||||
if (!dirtyRef.current) return
|
if (!dirtyRef.current) return
|
||||||
const payload = lastPayloadRef.current
|
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
|
payload.draftId = draftIdRef.current
|
||||||
void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {})
|
void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
@ -1479,6 +1543,10 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
if (payload) {
|
if (payload) {
|
||||||
payload.draftId = draftIdRef.current
|
payload.draftId = draftIdRef.current
|
||||||
void window.ipc.invoke('gmail:saveDraft', payload).catch(() => {})
|
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()
|
onClose()
|
||||||
|
|
@ -1512,14 +1580,7 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build References chain from all known message ids (newest last).
|
const { threadId, inReplyTo, references } = threadingHeaders(mode, thread)
|
||||||
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
|
|
||||||
|
|
||||||
// Stop autosave from racing the send (it would leave an orphaned draft), and
|
// 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.
|
// 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()
|
await waitForSaveIdle()
|
||||||
try {
|
try {
|
||||||
const result = await window.ipc.invoke('gmail:sendReply', {
|
const result = await window.ipc.invoke('gmail:sendReply', {
|
||||||
threadId: isThreaded ? thread?.threadId : undefined,
|
threadId,
|
||||||
to: toList.join(', '),
|
to: toList.join(', '),
|
||||||
cc: ccList.length ? ccList.join(', ') : undefined,
|
cc: ccList.length ? ccList.join(', ') : undefined,
|
||||||
bcc: bccList.length ? bccList.join(', ') : undefined,
|
bcc: bccList.length ? bccList.join(', ') : undefined,
|
||||||
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'),
|
subject: subject.trim() || (thread ? composeSubject(mode, thread.subject) : '(No subject)'),
|
||||||
bodyHtml: html,
|
bodyHtml: html,
|
||||||
bodyText: text,
|
bodyText: text,
|
||||||
inReplyTo: isThreaded ? inReplyTo : undefined,
|
inReplyTo,
|
||||||
references: isThreaded ? references || undefined : undefined,
|
references,
|
||||||
attachments: attachments.length
|
attachments: attachments.length
|
||||||
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
? attachments.map(({ filename, mimeType, contentBase64 }) => ({ filename, mimeType, contentBase64 }))
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -1785,8 +1846,11 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
<Button type="button" variant="outline" size="xs" onClick={cancelLink}>Cancel</Button>
|
<Button type="button" variant="outline" size="xs" onClick={cancelLink}>Cancel</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-3 border-t border-border px-3 py-2.5">
|
{/* flex-wrap: every button here is shrink-0, so on a narrow pane the
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1821,10 +1885,18 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editor && <ComposeToolbar editor={editor} onOpenLink={openLink} />}
|
{/* Toolbar + Discard share one wrap unit so Discard never strands on
|
||||||
<Button type="button" variant="ghost" size="sm" className="text-muted-foreground" onClick={handleDiscard}>
|
a line of its own: wide panes show a single row (unit grows, so
|
||||||
Discard
|
ml-auto pins Discard to the right edge); narrow panes wrap the
|
||||||
</Button>
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -2329,8 +2401,6 @@ 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)
|
||||||
|
|
@ -2407,16 +2477,6 @@ 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
|
||||||
|
|
@ -2509,38 +2569,6 @@ export function EmailView({ initialThreadId, threadIdVersion }: EmailViewProps =
|
||||||
}
|
}
|
||||||
}, [updateThreadInState])
|
}, [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) => {
|
const archiveThreadAction = useCallback(async (threadId: string) => {
|
||||||
// Start the slide-out right away; the row is removed once both the IPC
|
// 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.
|
// 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">
|
<section className="gmail-section">
|
||||||
<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">
|
<span>
|
||||||
<div className="flex items-center gap-1.5" title="Mark Everything else as read — applies to these and future emails">
|
{other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
|
||||||
<span className="text-xs text-muted-foreground">Mark as read</span>
|
</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>
|
|
||||||
</div>
|
</div>
|
||||||
{visibleOther.map(renderRow)}
|
{visibleOther.map(renderRow)}
|
||||||
{!other.hasReachedEnd && (
|
{!other.hasReachedEnd && (
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,6 @@ 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 {
|
||||||
|
|
@ -74,15 +67,3 @@ export function setMaxEmails(maxEmails: number): void {
|
||||||
writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) });
|
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 { 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, getAutoReadEverythingElse } from '../config/gmail_sync_config.js';
|
import { getMaxEmails } 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';
|
||||||
|
|
@ -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 {
|
interface SyncedThread {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
|
|
@ -308,6 +233,14 @@ export interface GmailThreadSnapshot {
|
||||||
}>;
|
}>;
|
||||||
messageIdHeader?: string;
|
messageIdHeader?: string;
|
||||||
isDraft?: boolean;
|
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);
|
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);
|
||||||
}
|
}
|
||||||
|
|
@ -1562,6 +1478,14 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
|
||||||
for (const item of record.messagesAdded) {
|
for (const item of record.messagesAdded) {
|
||||||
const labels = item.message?.labelIds ?? [];
|
const labels = item.message?.labelIds ?? [];
|
||||||
if (labels.includes('SPAM') || labels.includes('TRASH')) continue;
|
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) {
|
if (item.message?.threadId) {
|
||||||
threadIds.add(item.message.threadId);
|
threadIds.add(item.message.threadId);
|
||||||
}
|
}
|
||||||
|
|
@ -2023,16 +1947,37 @@ export async function saveThreadDraft(opts: SaveDraftOptions): Promise<SaveDraft
|
||||||
id: draftId,
|
id: draftId,
|
||||||
requestBody: { message },
|
requestBody: { message },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
// The draft was likely deleted or already sent; start a new one.
|
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 } });
|
res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } });
|
res = await gmailClient.users.drafts.create({ userId: 'me', requestBody: { message } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wake the sync loop so the local cache reflects the draft.
|
// Mirror the draft body onto the thread's cached snapshot so reopening
|
||||||
triggerSync();
|
// 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 };
|
return { draftId: res.data.id || undefined };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -2091,6 +2036,12 @@ async function buildDraftSnapshot(
|
||||||
const threadId = msg.threadId || draftId;
|
const threadId = msg.threadId || draftId;
|
||||||
const messageIdHeader =
|
const messageIdHeader =
|
||||||
headerValue(headers, 'Message-ID') || headerValue(headers, 'Message-Id') || undefined;
|
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 {
|
return {
|
||||||
threadId,
|
threadId,
|
||||||
|
|
@ -2114,6 +2065,8 @@ async function buildDraftSnapshot(
|
||||||
bodyHtml: parts.html || undefined,
|
bodyHtml: parts.html || undefined,
|
||||||
messageIdHeader,
|
messageIdHeader,
|
||||||
isDraft: true,
|
isDraft: true,
|
||||||
|
inReplyToHeader,
|
||||||
|
referencesHeader,
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,12 @@ export const GmailThreadMessageSchema = z.object({
|
||||||
messageIdHeader: z.string().optional(),
|
messageIdHeader: z.string().optional(),
|
||||||
// Set on the unsent draft message within a thread (used by the Drafts view).
|
// Set on the unsent draft message within a thread (used by the Drafts view).
|
||||||
isDraft: z.boolean().optional(),
|
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>;
|
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() }),
|
req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }),
|
||||||
res: z.object({ ok: z.boolean(), error: z.string().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': {
|
'gmail:downloadAttachment': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
messageId: z.string().min(1),
|
messageId: z.string().min(1),
|
||||||
|
|
@ -300,14 +296,6 @@ const ipcSchemas = {
|
||||||
}),
|
}),
|
||||||
res: z.object({ ok: z.boolean(), error: 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