Merge pull request #650 from rowboatlabs/mail-enhancements

feat(email): drafts, search, read-state controls & Superhuman-style shortcuts
This commit is contained in:
arkml 2026-07-03 01:12:40 +05:30 committed by GitHub
commit 3ba94402d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2064 additions and 259 deletions

View file

@ -74,7 +74,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, archiveThread, trashThread, markThreadRead, 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 { 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';
@ -782,6 +782,18 @@ export function setupIpcHandlers() {
'gmail:sendReply': async (_event, args) => {
return sendThreadReply(args);
},
'gmail:saveDraft': async (_event, args) => {
return saveThreadDraft(args);
},
'gmail:deleteDraft': async (_event, args) => {
return deleteThreadDraft(args.draftId);
},
'gmail:getDrafts': async () => {
return listDraftThreads();
},
'gmail:search': async (_event, args) => {
return searchThreads(args.query, { limit: args.limit });
},
'gmail:getConnectionStatus': async () => {
return getGmailConnectionStatus();
},
@ -798,7 +810,10 @@ export function setupIpcHandlers() {
return trashThread(args.threadId);
},
'gmail:markThreadRead': async (_event, args) => {
return markThreadRead(args.threadId);
return markThreadRead(args.threadId, args.read);
},
'gmail:downloadAttachment': async (_event, args) => {
return downloadAttachment(args);
},
'gmail:saveMessageHeight': async (_event, args) => {
saveMessageBodyHeight(args.threadId, args.messageId, args.height);

View file

@ -169,6 +169,25 @@
color: var(--gm-placeholder);
}
.gmail-search-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--gm-text-muted);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.gmail-search-clear:hover {
background: var(--gm-icon-hover-bg);
color: var(--gm-text);
}
.gmail-icon-button {
display: inline-flex;
align-items: center;
@ -205,6 +224,46 @@
flex-direction: column;
}
/* Native list virtualization: offscreen rows skip layout and paint entirely.
Applied only to rows without a mounted ThreadDetail (those hold iframes and
composers, which must keep rendering while offscreen). */
.gmail-row-group-cv {
content-visibility: auto;
contain-intrinsic-size: auto 40px;
}
/* While the list is scrolling, rows ignore the pointer so hover restyles and
prefetch timers don't compete with frame rendering. */
.gmail-shell[data-scrolling] .gmail-row-shell {
pointer-events: none;
}
/* Archived/trashed rows slide out and collapse before removal. Removing the
class (failed action) snaps the row back. */
.gmail-row-group-leaving {
overflow: hidden;
pointer-events: none;
animation: gmail-row-leave 160ms ease-in forwards;
}
@keyframes gmail-row-leave {
0% {
opacity: 1;
transform: translateX(0);
max-height: 48px;
}
60% {
opacity: 0;
transform: translateX(32px);
max-height: 48px;
}
100% {
opacity: 0;
transform: translateX(32px);
max-height: 0;
}
}
.gmail-list-header {
position: sticky;
top: 0;
@ -319,6 +378,20 @@
background: var(--gm-bg-row-selected-hover);
}
/* The j/k keyboard cursor. Declared after the hover rules (same specificity)
so the focus ring survives hovering the focused row. */
.gmail-row-focused,
.gmail-row-focused:hover {
background: var(--gm-bg-row-hover);
box-shadow: inset 0 0 0 1px var(--gm-accent);
}
.gmail-row-selected.gmail-row-focused,
.gmail-row-selected.gmail-row-focused:hover {
background: var(--gm-bg-row-selected);
box-shadow: inset 2px 0 0 var(--gm-accent), inset 0 0 0 1px var(--gm-accent);
}
.gmail-row-unread {
color: var(--gm-text);
}
@ -394,12 +467,50 @@
border-top: 1px solid var(--gm-border);
border-bottom: 1px solid var(--gm-border);
box-shadow: inset 2px 0 0 var(--gm-accent);
/* Replays whenever the detail is shown hidden details are display: none,
so un-hiding restarts the animation. */
animation: gmail-detail-open 140ms ease-out;
}
.gmail-detail-hidden {
display: none;
}
@keyframes gmail-detail-open {
from {
opacity: 0;
transform: translateY(-6px);
}
to {
opacity: 1;
transform: none;
}
}
/* The inline reply/forward composer pops in from below the thread. */
.gmail-compose-inline {
animation: gmail-compose-open 140ms ease-out;
}
@keyframes gmail-compose-open {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.gmail-detail-inline,
.gmail-compose-inline,
.gmail-row-group-leaving {
animation: none;
}
}
.gmail-detail-toolbar {
display: flex;
align-items: center;

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,17 @@ function ensureDefaultConfigs() {
configured: false
}, null, 2));
}
// Create gmail_sync.json with the default onboarding email count if it
// doesn't exist, so the "how many emails to backfill" setting is
// discoverable and editable. Keep the default in sync with
// DEFAULT_MAX_EMAILS in gmail_sync_config.ts.
const gmailSyncConfig = path.join(WorkDir, "config", "gmail_sync.json");
if (!fs.existsSync(gmailSyncConfig)) {
fs.writeFileSync(gmailSyncConfig, JSON.stringify({
maxEmails: 500
}, null, 2));
}
}
ensureDirs();

View file

@ -0,0 +1,69 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from './config.js';
const CONFIG_FILE = path.join(WorkDir, 'config', 'gmail_sync.json');
/**
* How many of the newest email threads the initial (onboarding) / recovery
* Gmail sync pulls down. This bounds the sync by a COUNT of recent threads
* rather than a fixed date window, so a fresh account backfills its most recent
* `maxEmails` emails even when they span more than a week.
*/
export const DEFAULT_MAX_EMAILS = 500;
// Guard rails: at least one email, and a hard ceiling so a misconfigured value
// can't trigger a runaway onboarding sync (each thread costs a threads.get plus
// an LLM classification).
const MIN_MAX_EMAILS = 1;
const MAX_MAX_EMAILS = 5000;
interface GmailSyncConfig {
maxEmails: number;
}
function clampMaxEmails(value: number): number {
return Math.max(MIN_MAX_EMAILS, Math.min(MAX_MAX_EMAILS, Math.floor(value)));
}
function readConfig(): Partial<GmailSyncConfig> {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
return JSON.parse(raw) as Partial<GmailSyncConfig>;
}
} catch (err) {
console.warn('[GmailSyncConfig] Failed to read gmail_sync.json:', err);
}
return {};
}
function writeConfig(config: Partial<GmailSyncConfig>): void {
const configDir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/**
* Read the configured max email count for the onboarding/full sync.
* Falls back to {@link DEFAULT_MAX_EMAILS} when the file is missing, malformed,
* or holds an out-of-range value.
*/
export function getMaxEmails(): number {
const value = Number(readConfig()?.maxEmails);
if (Number.isFinite(value) && value > 0) {
return clampMaxEmails(value);
}
return DEFAULT_MAX_EMAILS;
}
/**
* Persist the max email count used by the onboarding/full sync. The value is
* clamped into the supported range before writing.
*/
export function setMaxEmails(maxEmails: number): void {
writeConfig({ ...readConfig(), maxEmails: clampMaxEmails(maxEmails) });
}

View file

@ -4,6 +4,7 @@ import { google, gmail_v1 as gmail } from 'googleapis';
import { NodeHtmlMarkdown } from 'node-html-markdown'
import { OAuth2Client } from 'google-auth-library';
import { WorkDir } from '../config/config.js';
import { getMaxEmails } from '../config/gmail_sync_config.js';
import { GoogleClientFactory } from './google-client-factory.js';
import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js';
import { limitEventItems } from './limit_event_items.js';
@ -15,6 +16,10 @@ import { notifyIfEnabled } from '../application/notification/notifier.js';
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
const LEGACY_CACHE_DIR = path.join(SYNC_DIR, 'cache');
const CACHE_DIR = path.join(WorkDir, 'inbox_lists');
// Local index of full-text search results. Kept separate from inbox_lists/ so it
// never leaks non-inbox threads into the inbox view. Grows as you search; we
// don't prune it (the user wants a durable local index).
const SEARCH_CACHE_DIR = path.join(WorkDir, 'search_index');
(function migrateLegacyCacheDir() {
try {
@ -95,6 +100,35 @@ function deleteCachedSnapshot(threadId: string): void {
}
}
// Local search index — same on-disk shape as the inbox cache, separate dir.
function searchCachePath(threadId: string): string {
return path.join(SEARCH_CACHE_DIR, `${encodeURIComponent(threadId)}.json`);
}
function readSearchSnapshot(threadId: string): SnapshotCacheEntry | null {
try {
const raw = fs.readFileSync(searchCachePath(threadId), 'utf-8');
return JSON.parse(raw) as SnapshotCacheEntry;
} catch {
return null;
}
}
function writeSearchSnapshot(threadId: string, historyId: string, snapshot: GmailThreadSnapshot): void {
try {
if (!fs.existsSync(SEARCH_CACHE_DIR)) fs.mkdirSync(SEARCH_CACHE_DIR, { recursive: true });
const entry: SnapshotCacheEntry = {
historyId,
fetchedAt: new Date().toISOString(),
parserVersion: SNAPSHOT_PARSER_VERSION,
snapshot,
};
fs.writeFileSync(searchCachePath(threadId), JSON.stringify(entry), 'utf-8');
} catch (err) {
console.warn(`[Gmail search index] write failed for ${threadId}:`, err);
}
}
async function getGmailClientOrThrow() {
const auth = await GoogleClientFactory.getClient();
if (!auth) throw new Error('Gmail is not connected.');
@ -132,19 +166,19 @@ export async function trashThread(threadId: string): Promise<ThreadActionResult>
}
}
export async function markThreadRead(threadId: string): Promise<ThreadActionResult> {
export async function markThreadRead(threadId: string, read: boolean = true): Promise<ThreadActionResult> {
try {
const gmailClient = await getGmailClientOrThrow();
await gmailClient.users.threads.modify({
userId: 'me',
id: threadId,
requestBody: { removeLabelIds: ['UNREAD'] },
requestBody: read ? { removeLabelIds: ['UNREAD'] } : { addLabelIds: ['UNREAD'] },
});
// Update local cache: clear unread on all messages in the thread.
// Mirror the new read state onto every message in the cached thread.
const cached = readCachedSnapshot(threadId);
if (cached) {
for (const m of cached.snapshot.messages) m.unread = false;
cached.snapshot.unread = false;
for (const m of cached.snapshot.messages) m.unread = !read;
cached.snapshot.unread = !read;
try {
fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8');
} catch (err) {
@ -176,6 +210,8 @@ export interface GmailThreadSnapshot {
importance?: 'important' | 'other';
draft_response?: string;
gmail_draft?: string;
/** Gmail-side draft id, present on entries from listDraftThreads. */
draftId?: string;
messages: Array<{
id?: string;
from?: string;
@ -192,8 +228,19 @@ export interface GmailThreadSnapshot {
mimeType?: string;
sizeBytes?: number;
savedPath: string;
messageId?: string;
attachmentId?: string;
}>;
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;
}>;
}
@ -347,6 +394,10 @@ interface ExtractedAttachment {
mimeType?: string;
sizeBytes?: number;
savedPath: string;
// Gmail identifiers needed to fetch the attachment on demand (e.g. when a
// search result's attachment hasn't been downloaded to disk yet).
messageId?: string;
attachmentId?: string;
}
/**
@ -383,6 +434,8 @@ function extractAttachments(msgId: string, payload: gmail.Schema$MessagePart, ht
mimeType: part.mimeType ?? undefined,
sizeBytes: typeof part.body?.size === 'number' ? part.body.size : undefined,
savedPath: `gmail_sync/attachments/${safeName}`,
messageId: msgId,
attachmentId: attId,
});
}
}
@ -793,6 +846,45 @@ async function buildAndCacheSnapshot(
) {
return cached.snapshot;
}
const snapshot = await parseThreadSnapshot(threadId, threadData, gmailClient);
if (!snapshot) return null;
try {
const userEmail = await getUserEmail(auth);
const skipDraft = (snapshot.gmail_draft?.length ?? 0) > 0;
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
snapshot.importance = classification.importance;
if (classification.summary) snapshot.summary = classification.summary;
if (classification.draftResponse) {
const draftResponse = stripGmailQuotedReplyText(classification.draftResponse);
if (draftResponse) snapshot.draft_response = draftResponse;
}
} catch (err) {
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
}
if (threadData.historyId) {
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
}
return snapshot;
}
/**
* Parse a threads.get response into a snapshot WITHOUT AI classification or
* caching the shared core of buildAndCacheSnapshot, also used by search (which
* doesn't need importance/summary). Returns null when there are no visible
* (non-draft) messages.
*/
async function parseThreadSnapshot(
threadId: string,
threadData: gmail.Schema$Thread,
gmailClient: gmail.Gmail,
): Promise<GmailThreadSnapshot | null> {
const messages = threadData.messages;
if (!messages || messages.length === 0) return null;
const cached = readCachedSnapshot(threadId);
const heightCarryover = new Map<string, number>();
if (cached) {
for (const m of cached.snapshot.messages) {
@ -856,7 +948,7 @@ async function buildAndCacheSnapshot(
.filter(Boolean)
.join('\n\n');
const snapshot: GmailThreadSnapshot = {
return {
threadId,
threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`,
subject: latest.subject || visibleMessages[0]?.subject,
@ -869,26 +961,6 @@ async function buildAndCacheSnapshot(
messages: visibleMessages,
gmail_draft: latestDraftBody || undefined,
};
try {
const userEmail = await getUserEmail(auth);
const skipDraft = latestDraftBody.length > 0;
const classification = await classifyThread(snapshot, userEmail, { skipDraft });
snapshot.importance = classification.importance;
if (classification.summary) snapshot.summary = classification.summary;
if (classification.draftResponse) {
const draftResponse = stripGmailQuotedReplyText(classification.draftResponse);
if (draftResponse) snapshot.draft_response = draftResponse;
}
} catch (err) {
console.warn(`[Gmail] classify failed for ${threadId}:`, err);
}
if (threadData.historyId) {
writeCachedSnapshot(threadId, threadData.historyId, snapshot);
}
return snapshot;
}
async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise<string | null> {
@ -920,6 +992,83 @@ async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string,
return null;
}
export interface DownloadAttachmentResult {
ok: boolean;
error?: string;
}
/**
* Ensure an attachment referenced by a snapshot exists on disk, downloading it
* on demand when it doesn't. Inbox attachments are saved during sync, but
* search results build snapshots without downloading, so opening one of their
* attachments needs this. `savedPath` is the workspace-relative path stored on
* the attachment; `attachmentId` (when supplied) is tried first, falling back
* to re-fetching the message and locating the part by filename attachment ids
* can go stale on a cached snapshot, whereas the file name is stable.
*/
export async function downloadAttachment(args: {
messageId: string;
savedPath: string;
attachmentId?: string;
}): Promise<DownloadAttachmentResult> {
try {
const { messageId, savedPath, attachmentId } = args;
if (!messageId || !savedPath) return { ok: false, error: 'Missing attachment reference.' };
const absPath = path.join(WorkDir, savedPath);
if (fs.existsSync(absPath)) return { ok: true };
const gmailClient = await getGmailClientOrThrow();
const dir = path.dirname(absPath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const fetchData = async (attId: string): Promise<string | null> => {
const res = await gmailClient.users.messages.attachments.get({
userId: 'me',
messageId,
id: attId,
});
return res.data.data ?? null;
};
let data: string | null = null;
if (attachmentId) {
try {
data = await fetchData(attachmentId);
} catch (err) {
console.warn(`[Gmail] attachment fetch by id failed for ${messageId}, retrying by filename:`, err);
}
}
if (!data) {
// Re-fetch the message and locate the attachment part whose derived
// saved name matches the requested savedPath.
const wanted = path.basename(savedPath);
const msg = await gmailClient.users.messages.get({ userId: 'me', id: messageId, format: 'full' });
let foundAttId: string | undefined;
const walk = (part: gmail.Schema$MessagePart): void => {
if (foundAttId) return;
const fn = part.filename;
const attId = part.body?.attachmentId;
if (fn && attId && `${messageId}_${cleanFilename(fn)}` === wanted) {
foundAttId = attId;
return;
}
if (part.parts) for (const sub of part.parts) walk(sub);
};
if (msg.data.payload) walk(msg.data.payload);
if (!foundAttId) return { ok: false, error: 'Attachment not found in message.' };
data = await fetchData(foundAttId);
}
if (!data) return { ok: false, error: 'Attachment had no data.' };
fs.writeFileSync(absPath, Buffer.from(data, 'base64'));
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// --- Sync Logic ---
async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string): Promise<SyncedThread | null> {
@ -1149,24 +1298,29 @@ async function backfillMissingRecentThreads(
async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) {
const gmail = google.gmail({ version: 'v1', auth });
// If the state file holds a last_sync timestamp (e.g. left over from a
// prior Composio sync, or from a previous successful native sync that
// we're falling back to after a history.list 404), use that as the
// floor — but never reach back further than lookbackDays. This caps the
// window at "1 week at most": if last_sync is within the lookback window
// we resume from it (a smaller window), otherwise we clamp to lookbackDays
// ago. Mail older than the cap that arrived during a long offline gap is
// intentionally skipped rather than backfilled.
// The onboarding / recovery fetch is bounded by a COUNT of the most recent
// threads (maxEmails, configurable — default 500), not by a fixed date
// window. So a fresh account pulls its newest `maxEmails` emails even when
// they span more than a week.
//
// When we can resume after a previous successful sync (a last_sync within
// the lookback window — e.g. the history.list 404 fallback, or a prior
// Composio sync), we still floor the query at last_sync so only genuinely
// new mail is re-walked, and the count cap acts purely as a safety bound.
// With no resumable last_sync (first connect, or a gap longer than the
// lookback window) we drop the date floor entirely and just take the newest
// `maxEmails` threads.
const maxEmails = getMaxEmails();
const state = loadState(stateFile);
const lookbackFloor = new Date();
lookbackFloor.setDate(lookbackFloor.getDate() - lookbackDays);
let pastDate: Date;
if (state.last_sync && new Date(state.last_sync) > lookbackFloor) {
pastDate = new Date(state.last_sync);
console.log(`Performing full sync from last_sync=${state.last_sync}...`);
const resumeFrom = state.last_sync && new Date(state.last_sync) > lookbackFloor
? new Date(state.last_sync)
: null;
if (resumeFrom) {
console.log(`Performing full sync from last_sync=${state.last_sync} (max ${maxEmails} threads)...`);
} else {
pastDate = lookbackFloor;
console.log(`Performing full sync of last ${lookbackDays} days...`);
console.log(`Performing full sync of the newest ${maxEmails} threads...`);
}
let run: ServiceRunContext | null = null;
@ -1181,19 +1335,24 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
};
try {
const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/');
const baseQuery = '-in:spam -in:trash';
const q = resumeFrom
? `after:${resumeFrom.toISOString().split('T')[0].replace(/-/g, '/')} ${baseQuery}`
: baseQuery;
// Get History ID
const profile = await gmail.users.getProfile({ userId: 'me' });
const currentHistoryId = profile.data.historyId!;
// Gmail returns threads newest-first, so paginating until we've collected
// maxEmails ids yields the most recent maxEmails threads.
const threadIds: string[] = [];
let pageToken: string | undefined;
do {
const res = await gmail.users.threads.list({
userId: 'me',
q: `after:${dateQuery} -in:spam -in:trash`,
maxResults: 500,
q,
maxResults: Math.min(500, maxEmails),
pageToken
});
@ -1206,7 +1365,9 @@ async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: str
}
}
pageToken = res.data.nextPageToken ?? undefined;
} while (pageToken);
} while (pageToken && threadIds.length < maxEmails);
if (threadIds.length > maxEmails) threadIds.length = maxEmails;
if (threadIds.length === 0) {
saveState(currentHistoryId, stateFile);
@ -1318,6 +1479,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);
}
@ -1435,10 +1604,10 @@ async function performSync() {
// partial-sync on subsequent calls.
const cacheMissing = !fs.existsSync(CACHE_DIR) || fs.readdirSync(CACHE_DIR).length === 0;
// partialSync replays *every* messageAdded since the stored historyId,
// regardless of date — so after a long offline gap a still-valid
// historyId would pull the entire gap (e.g. 3 weeks). To honor the
// "1 week at most" cap, bypass it when last_sync is older than the
// lookback window and run a (date-clamped) fullSync instead.
// regardless of date/count — so after a long offline gap a still-valid
// historyId would pull the entire gap (e.g. 3 weeks). When last_sync is
// older than the lookback window, bypass it and run fullSync instead,
// which is count-bounded (the newest maxEmails threads).
const gapMs = state.last_sync ? Date.now() - new Date(state.last_sync).getTime() : 0;
const gapTooLarge = gapMs > LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
if (!state.historyId) {
@ -1448,7 +1617,7 @@ async function performSync() {
console.log("History ID present but inbox cache empty — running full sync to backfill snapshots...");
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} else if (gapTooLarge) {
console.log(`Last sync older than ${LOOKBACK_DAYS} days — running full sync clamped to the lookback window instead of partial sync...`);
console.log(`Last sync older than ${LOOKBACK_DAYS} days — running count-bounded full sync instead of partial sync...`);
await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS);
} else {
console.log("History ID found, starting partial sync...");
@ -1486,6 +1655,19 @@ export interface SendReplyResult {
error?: string;
}
export interface SaveDraftOptions extends Omit<SendReplyOptions, 'to'> {
/** Recipient may be blank while a draft is still being written. */
to?: string;
/** Existing Gmail draft to update; omitted on first save (creates a new one). */
draftId?: string;
}
export interface SaveDraftResult {
/** The Gmail-side draft id, to be passed back on subsequent saves. */
draftId?: string;
error?: string;
}
export interface GmailConnectionStatus {
connected: boolean;
hasRequiredScope: boolean;
@ -1589,6 +1771,88 @@ function sanitizeAttachmentName(name: string): string {
return (name || 'attachment').replace(/[\r\n"\\]/g, '_').trim() || 'attachment';
}
// Build the raw (base64url) RFC 2822 message shared by both send and draft-save.
// Recipient headers are omitted when blank, so an in-progress draft with no
// `To` yet still produces a valid message. `isEmpty` lets callers reject a
// whitespace-only body without re-parsing the result.
function buildRawMimeMessage(opts: SaveDraftOptions, userEmail: string): { raw: string; isEmpty: boolean } {
const safeTo = opts.to?.trim() ? requireSafeHeaderValue('To', opts.to) : undefined;
const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined;
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
const replyBody = opts.threadId
? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText)
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
const isEmpty = !replyBody.bodyText.trim();
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const altBoundary = `alt_${seed}`;
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
if (safeTo) headers.push(`To: ${safeTo}`);
if (safeCc) headers.push(`Cc: ${safeCc}`);
if (safeBcc) headers.push(`Bcc: ${safeBcc}`);
headers.push(`Subject: ${encodeRfc2047(opts.subject)}`);
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
// The text+html body as a self-contained multipart/alternative block.
const altParts: string[] = [];
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/plain; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyText));
altParts.push('');
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/html; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
altParts.push('');
altParts.push(`--${altBoundary}--`);
let body: string;
if (attachments.length) {
// Wrap the alternative body plus each attachment in a multipart/mixed.
const mixedBoundary = `mixed_${seed}`;
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
const mixed: string[] = [];
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
mixed.push('');
mixed.push(altParts.join('\r\n'));
for (const att of attachments) {
const name = sanitizeAttachmentName(att.filename);
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: ${mime}; name="${name}"`);
mixed.push('Content-Transfer-Encoding: base64');
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
mixed.push('');
mixed.push(wrapBase64(att.contentBase64));
mixed.push('');
}
mixed.push(`--${mixedBoundary}--`);
body = mixed.join('\r\n');
} else {
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
body = altParts.join('\r\n');
}
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return { raw, isEmpty };
}
export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReplyResult> {
try {
const auth = await GoogleClientFactory.getClient();
@ -1598,82 +1862,11 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
const userEmail = await getUserEmail(auth);
if (!userEmail) return { error: 'Could not determine your Gmail address.' };
const safeTo = requireSafeHeaderValue('To', opts.to);
const safeCc = opts.cc?.trim() ? requireSafeHeaderValue('Cc', opts.cc) : undefined;
const safeBcc = opts.bcc?.trim() ? requireSafeHeaderValue('Bcc', opts.bcc) : undefined;
const safeInReplyTo = opts.inReplyTo ? requireSafeHeaderValue('In-Reply-To', opts.inReplyTo) : undefined;
const safeReferences = opts.references ? requireSafeHeaderValue('References', opts.references) : undefined;
const replyBody = opts.threadId
? sanitizeReplyBodyForGmailReply(opts.bodyHtml, opts.bodyText)
: { bodyHtml: opts.bodyHtml.trim(), bodyText: opts.bodyText.trim() };
if (!replyBody.bodyText.trim()) return { error: 'Draft is empty.' };
if (!opts.to?.trim()) return { error: 'Add at least one recipient.' };
const built = buildRawMimeMessage(opts, userEmail);
if (built.isEmpty) return { error: 'Draft is empty.' };
const seed = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const altBoundary = `alt_${seed}`;
const attachments = (opts.attachments ?? []).filter((a) => a.contentBase64);
const headers: string[] = [];
headers.push(`From: ${requireSafeHeaderValue('From', userEmail)}`);
headers.push(`To: ${safeTo}`);
if (safeCc) headers.push(`Cc: ${safeCc}`);
if (safeBcc) headers.push(`Bcc: ${safeBcc}`);
headers.push(`Subject: ${encodeRfc2047(opts.subject)}`);
if (safeInReplyTo) headers.push(`In-Reply-To: ${safeInReplyTo}`);
if (safeReferences) headers.push(`References: ${safeReferences}`);
headers.push('MIME-Version: 1.0');
// The text+html body as a self-contained multipart/alternative block.
const altParts: string[] = [];
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/plain; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyText));
altParts.push('');
altParts.push(`--${altBoundary}`);
altParts.push('Content-Type: text/html; charset="UTF-8"');
altParts.push('Content-Transfer-Encoding: base64');
altParts.push('');
altParts.push(encodeMimeBase64(replyBody.bodyHtml));
altParts.push('');
altParts.push(`--${altBoundary}--`);
let body: string;
if (attachments.length) {
// Wrap the alternative body plus each attachment in a multipart/mixed.
const mixedBoundary = `mixed_${seed}`;
headers.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`);
const mixed: string[] = [];
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
mixed.push('');
mixed.push(altParts.join('\r\n'));
for (const att of attachments) {
const name = sanitizeAttachmentName(att.filename);
const mime = sanitizeAttachmentName(att.mimeType) || 'application/octet-stream';
mixed.push(`--${mixedBoundary}`);
mixed.push(`Content-Type: ${mime}; name="${name}"`);
mixed.push('Content-Transfer-Encoding: base64');
mixed.push(`Content-Disposition: attachment; filename="${name}"`);
mixed.push('');
mixed.push(wrapBase64(att.contentBase64));
mixed.push('');
}
mixed.push(`--${mixedBoundary}--`);
body = mixed.join('\r\n');
} else {
headers.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
body = altParts.join('\r\n');
}
const message = `${headers.join('\r\n')}\r\n\r\n${body}`;
const raw = Buffer.from(message, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
const requestBody: gmail.Schema$Message = { raw };
const requestBody: gmail.Schema$Message = { raw: built.raw };
if (opts.threadId) requestBody.threadId = opts.threadId;
const res = await gmailClient.users.messages.send({
@ -1707,6 +1900,294 @@ export async function sendThreadReply(opts: SendReplyOptions): Promise<SendReply
}
}
/**
* Save the composer's contents as a Gmail draft. Drafts created here live in the
* user's real Gmail account, so they show up in the Drafts folder of every Gmail
* client and sync back down via the normal history sync (the `gmail_draft` field).
*
* Passing `draftId` updates that existing draft in place. If it's omitted but a
* draft already exists for `threadId` (e.g. a reply opened in a new session),
* that draft is reused instead of creating a duplicate. A stale `draftId`
* (deleted/sent elsewhere) falls back to creating a fresh draft.
*/
export async function saveThreadDraft(opts: SaveDraftOptions): Promise<SaveDraftResult> {
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return { error: 'Gmail is not connected.' };
const gmailClient = google.gmail({ version: 'v1', auth });
const userEmail = await getUserEmail(auth);
if (!userEmail) return { error: 'Could not determine your Gmail address.' };
const built = buildRawMimeMessage(opts, userEmail);
if (built.isEmpty) return { error: 'Draft is empty.' };
const message: gmail.Schema$Message = { raw: built.raw };
if (opts.threadId) message.threadId = opts.threadId;
// Resolve which draft to update: explicit id wins; otherwise reuse an
// existing draft on the same thread so replies don't pile up duplicates.
let draftId = opts.draftId;
if (!draftId && opts.threadId) {
try {
const drafts = await gmailClient.users.drafts.list({ userId: 'me' });
const existing = (drafts.data.drafts || []).find(
(d) => d.message?.threadId === opts.threadId && d.id
);
if (existing?.id) draftId = existing.id;
} catch {
// Listing failed — fall through and create a new draft.
}
}
let res;
if (draftId) {
try {
res = await gmailClient.users.drafts.update({
userId: 'me',
id: draftId,
requestBody: { message },
});
} 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 } });
}
// 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) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
/** Delete a Gmail draft by id. A missing draft is treated as success. */
export async function deleteThreadDraft(draftId: string): Promise<{ ok: boolean; error?: string }> {
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return { ok: false, error: 'Gmail is not connected.' };
const gmailClient = google.gmail({ version: 'v1', auth });
await gmailClient.users.drafts.delete({ userId: 'me', id: draftId });
triggerSync();
return { ok: true };
} catch (err) {
const code = (err as { code?: number; response?: { status?: number } })?.code
?? (err as { response?: { status?: number } })?.response?.status;
// Already gone (sent/deleted) — nothing to do.
if (code === 404 || code === 410) return { ok: true };
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
// In-memory cache of built draft snapshots, keyed by draftId and validated by
// the draft's underlying message id. Gmail assigns a fresh message id whenever a
// draft is updated (locally via saveThreadDraft or in another client), so an
// unchanged message id means the parsed snapshot can be reused — we skip the
// per-draft drafts.get + body parse, mirroring listInboxPage's mtime cache.
interface DraftCacheEntry {
messageId: string;
snapshot: GmailThreadSnapshot;
}
const draftListCache = new Map<string, DraftCacheEntry>();
// Fetch one draft and parse it into a lightweight snapshot for the Drafts view.
async function buildDraftSnapshot(
gmailClient: gmail.Gmail,
draftId: string,
): Promise<GmailThreadSnapshot | null> {
const full = await gmailClient.users.drafts.get({ userId: 'me', id: draftId, format: 'full' });
const msg = full.data.message;
if (!msg) return null;
const headers = msg.payload?.headers || [];
const parts = msg.payload ? extractBodyParts(msg.payload) : { text: '', html: '' };
const rawBody = msg.payload ? normalizeBody(getBody(msg.payload)) : '';
const body = stripGmailQuotedReplyText(rawBody);
const subject = headerValue(headers, 'Subject') || '';
const from = headerValue(headers, 'From') || '';
const to = headerValue(headers, 'To') || '';
const cc = headerValue(headers, 'Cc') || '';
const date = headerValue(headers, 'Date') || '';
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,
threadUrl: `https://mail.google.com/mail/u/0/#drafts?compose=${draftId}`,
subject,
from,
to,
date,
latest_email: body,
gmail_draft: body || undefined,
draftId,
unread: false,
messages: [{
id: msg.id || undefined,
from,
to,
cc: cc || undefined,
date,
subject,
body,
bodyHtml: parts.html || undefined,
messageIdHeader,
isDraft: true,
inReplyToHeader,
referencesHeader,
}],
};
}
/**
* List the account's Gmail drafts (reply drafts and standalone new-message
* drafts) as lightweight thread snapshots for the Drafts view. Drafts aren't
* part of the INBOX snapshot cache, so we read them from the Gmail API but a
* cheap drafts.list (ids only) lets us reuse already-parsed snapshots for
* unchanged drafts and only drafts.get the new/edited ones. No AI
* classification; recipients/subject/body come straight off the draft message.
*/
export async function listDraftThreads(): Promise<{ threads: GmailThreadSnapshot[]; error?: string }> {
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) {
draftListCache.clear();
return { threads: [], error: 'Gmail is not connected.' };
}
const gmailClient = google.gmail({ version: 'v1', auth });
const list = await gmailClient.users.drafts.list({ userId: 'me', maxResults: 50 });
const drafts = list.data.drafts || [];
const seen = new Set<string>();
const built = await Promise.all(drafts.map(async (d) => {
if (!d.id) return null;
seen.add(d.id);
const messageId = d.message?.id || '';
const cached = draftListCache.get(d.id);
// Reuse the cached snapshot when the draft's message id is unchanged.
if (cached && messageId && cached.messageId === messageId) {
return cached.snapshot;
}
try {
const snapshot = await buildDraftSnapshot(gmailClient, d.id);
if (snapshot) draftListCache.set(d.id, { messageId, snapshot });
return snapshot;
} catch (err) {
console.warn('[Gmail] draft fetch failed:', err);
// Fall back to a stale cached copy if we have one.
return cached?.snapshot ?? null;
}
}));
// Evict cache entries for drafts that no longer exist (sent/deleted).
for (const key of draftListCache.keys()) {
if (!seen.has(key)) draftListCache.delete(key);
}
const threads = built.filter((s): s is GmailThreadSnapshot => s !== null);
// Newest first.
threads.sort((a, b) => {
const da = a.date ? Date.parse(a.date) : 0;
const db = b.date ? Date.parse(b.date) : 0;
return (Number.isFinite(db) ? db : 0) - (Number.isFinite(da) ? da : 0);
});
return { threads };
} catch (err) {
return { threads: [], error: err instanceof Error ? err.message : String(err) };
}
}
export interface SearchResult {
threads: GmailThreadSnapshot[];
error?: string;
}
/**
* Full-text search across the ENTIRE Gmail mailbox (not just locally-synced
* mail) using Gmail's `q` query. Each matching thread is parsed into a snapshot
* and written to the local search index so repeat searches and opening a
* result are instant. Reuses the inbox cache when a thread is already synced
* there. No AI classification.
*/
export async function searchThreads(query: string, opts: { limit?: number } = {}): Promise<SearchResult> {
const q = query.trim();
if (!q) return { threads: [] };
try {
const auth = await GoogleClientFactory.getClient();
if (!auth) return { threads: [], error: 'Gmail is not connected.' };
const gmailClient = google.gmail({ version: 'v1', auth });
// Generous cap so the index isn't artificially small (Gmail allows 500).
const limit = Math.max(1, Math.min(200, opts.limit ?? 100));
const list = await gmailClient.users.threads.list({ userId: 'me', q, maxResults: limit });
const ids = (list.data.threads || [])
.map((t) => t.id)
.filter((id): id is string => Boolean(id));
const built = await Promise.all(ids.map(async (threadId) => {
// Prefer the inbox snapshot (kept fresh by sync), then the search index.
const inboxCached = readCachedSnapshot(threadId);
if (inboxCached?.snapshot) return inboxCached.snapshot;
const indexed = readSearchSnapshot(threadId);
if (indexed?.snapshot) return indexed.snapshot;
try {
const threadData = await gmailClient.users.threads.get({ userId: 'me', id: threadId, format: 'full' });
const snapshot = await parseThreadSnapshot(threadId, threadData.data, gmailClient);
if (snapshot) writeSearchSnapshot(threadId, threadData.data.historyId || '', snapshot);
return snapshot;
} catch (err) {
console.warn(`[Gmail search] fetch failed for ${threadId}:`, err);
return null;
}
}));
const threads = built.filter((s): s is GmailThreadSnapshot => s !== null);
// Newest first.
threads.sort((a, b) => {
const da = a.date ? Date.parse(a.date) : 0;
const db = b.date ? Date.parse(b.date) : 0;
return (Number.isFinite(db) ? db : 0) - (Number.isFinite(da) ? da : 0);
});
return { threads };
} catch (err) {
return { threads: [], error: err instanceof Error ? err.message : String(err) };
}
}
export async function init() {
console.log("Starting Gmail Sync (TS)...");
console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`);

View file

@ -107,6 +107,10 @@ export const GmailAttachmentSchema = z.object({
mimeType: z.string().optional(),
sizeBytes: z.number().int().nonnegative().optional(),
savedPath: z.string(),
// Gmail identifiers used to fetch the attachment on demand when it hasn't
// been downloaded to disk yet (e.g. attachments on search results).
messageId: z.string().optional(),
attachmentId: z.string().optional(),
});
export type GmailAttachment = z.infer<typeof GmailAttachmentSchema>;
@ -124,6 +128,14 @@ export const GmailThreadMessageSchema = z.object({
bodyHeight: z.number().int().positive().optional(),
attachments: z.array(GmailAttachmentSchema).optional(),
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>;
@ -134,6 +146,9 @@ export const GmailThreadSchema = EmailBlockSchema.extend({
unread: z.boolean().optional(),
importance: z.enum(['important', 'other']).optional(),
gmail_draft: z.string().optional(),
// Gmail-side draft id, present on entries returned by the Drafts list so the
// composer can update/delete that exact draft.
draftId: z.string().optional(),
messages: z.array(GmailThreadMessageSchema),
});

View file

@ -207,6 +207,56 @@ const ipcSchemas = {
error: z.string().optional(),
}),
},
'gmail:saveDraft': {
req: z.object({
// Existing Gmail draft to update; omitted on first save (creates a new one).
draftId: z.string().min(1).optional(),
threadId: z.string().min(1).optional(),
// Recipients may be blank for a draft (unlike a send).
to: z.string().optional(),
cc: z.string().optional(),
bcc: z.string().optional(),
subject: z.string(),
bodyHtml: z.string(),
bodyText: z.string(),
inReplyTo: z.string().optional(),
references: z.string().optional(),
attachments: z
.array(
z.object({
filename: z.string(),
mimeType: z.string(),
contentBase64: z.string(),
}),
)
.optional(),
}),
res: z.object({
draftId: z.string().optional(),
error: z.string().optional(),
}),
},
'gmail:deleteDraft': {
req: z.object({ draftId: z.string().min(1) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:getDrafts': {
req: z.object({}),
res: z.object({
threads: z.array(GmailThreadSchema),
error: z.string().optional(),
}),
},
'gmail:search': {
req: z.object({
query: z.string(),
limit: z.number().int().positive().optional(),
}),
res: z.object({
threads: z.array(GmailThreadSchema),
error: z.string().optional(),
}),
},
'gmail:getConnectionStatus': {
req: z.object({}),
res: z.object({
@ -237,7 +287,15 @@ const ipcSchemas = {
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:markThreadRead': {
req: z.object({ threadId: z.string().min(1) }),
req: z.object({ threadId: z.string().min(1), read: z.boolean().optional() }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:downloadAttachment': {
req: z.object({
messageId: z.string().min(1),
savedPath: z.string().min(1),
attachmentId: z.string().optional(),
}),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
'gmail:saveMessageHeight': {