feat(email): drafts, search, bulk read/unread, configurable backfill

Gmail client enhancements in apps/x, bundling four independent features:

- Drafts: save/autosave, update, delete, and list Gmail drafts. The
  composer autosaves to a real Gmail draft (debounced ~1.5s) while
  typing, reuses the thread's existing draft so edits update in place,
  flushes a final save on close, and deletes the draft on discard. Adds
  isDraft/draftId to the shared thread types. New core helpers:
  saveThreadDraft, deleteThreadDraft, listDraftThreads,
  buildDraftSnapshot, buildRawMimeMessage.

- Search: searchThreads(query, {limit}) backed by an on-disk snapshot
  cache (read/writeSearchSnapshot). Extracts parseThreadSnapshot as the
  shared parse core reused by both the cache-building sync and search.

- Read state: markThreadRead now takes a `read` flag so it toggles
  read/unread; new markSectionRead marks a whole section
  (important/other) read/unread and returns the affected count.

- Backfill: the onboarding/recovery sync is now bounded by a
  configurable thread COUNT instead of a fixed 7-day window. New
  gmail_sync_config.ts (getMaxEmails/setMaxEmails) backed by
  ~/.rowboat/config/gmail_sync.json, default 500, clamped to 1-5000;
  seeded on first run.

New IPC channels: gmail:saveDraft, gmail:deleteDraft, gmail:getDrafts,
gmail:search, gmail:markSectionRead (plus a `read` field on
gmail:markThreadRead).
This commit is contained in:
hrsvrn 2026-07-01 16:18:52 +05:30
parent 4a26bc9d80
commit ab9bce6203
7 changed files with 1149 additions and 165 deletions

View file

@ -124,6 +124,8 @@ 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(),
});
export type GmailThreadMessage = z.infer<typeof GmailThreadMessageSchema>;
@ -134,6 +136,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

@ -205,6 +205,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({
@ -235,9 +285,13 @@ 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: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:saveMessageHeight': {
req: z.object({
threadId: z.string().min(1),