zele 0.3.0 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +1 -1
  2. package/bin/zele +27 -0
  3. package/dist/api-utils.d.ts +51 -2
  4. package/dist/api-utils.js +89 -3
  5. package/dist/api-utils.js.map +1 -1
  6. package/dist/auth.d.ts +27 -6
  7. package/dist/auth.js +185 -129
  8. package/dist/auth.js.map +1 -1
  9. package/dist/calendar-client.d.ts +16 -9
  10. package/dist/calendar-client.js +163 -59
  11. package/dist/calendar-client.js.map +1 -1
  12. package/dist/cli.js +26 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/attachment.js +17 -15
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.js +20 -9
  17. package/dist/commands/auth-cmd.js.map +1 -1
  18. package/dist/commands/calendar.js +67 -78
  19. package/dist/commands/calendar.js.map +1 -1
  20. package/dist/commands/draft.js +25 -18
  21. package/dist/commands/draft.js.map +1 -1
  22. package/dist/commands/label.js +33 -45
  23. package/dist/commands/label.js.map +1 -1
  24. package/dist/commands/mail-actions.js +11 -13
  25. package/dist/commands/mail-actions.js.map +1 -1
  26. package/dist/commands/mail.js +112 -126
  27. package/dist/commands/mail.js.map +1 -1
  28. package/dist/commands/profile.js +18 -21
  29. package/dist/commands/profile.js.map +1 -1
  30. package/dist/commands/watch.js +33 -261
  31. package/dist/commands/watch.js.map +1 -1
  32. package/dist/db.js +12 -13
  33. package/dist/db.js.map +1 -1
  34. package/dist/generated/browser.d.ts +12 -27
  35. package/dist/generated/client.d.ts +13 -28
  36. package/dist/generated/client.js +1 -1
  37. package/dist/generated/commonInputTypes.d.ts +90 -26
  38. package/dist/generated/enums.d.ts +0 -4
  39. package/dist/generated/enums.js +0 -3
  40. package/dist/generated/enums.js.map +1 -1
  41. package/dist/generated/internal/class.d.ts +22 -55
  42. package/dist/generated/internal/class.js +12 -4
  43. package/dist/generated/internal/class.js.map +1 -1
  44. package/dist/generated/internal/prismaNamespace.d.ts +272 -511
  45. package/dist/generated/internal/prismaNamespace.js +54 -66
  46. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  47. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
  48. package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
  49. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  50. package/dist/generated/models/Account.d.ts +1637 -0
  51. package/dist/generated/models/Account.js +2 -0
  52. package/dist/generated/models/Account.js.map +1 -0
  53. package/dist/generated/models/CalendarList.d.ts +1161 -0
  54. package/dist/generated/models/CalendarList.js +2 -0
  55. package/dist/generated/models/CalendarList.js.map +1 -0
  56. package/dist/generated/models/Label.d.ts +1161 -0
  57. package/dist/generated/models/Label.js +2 -0
  58. package/dist/generated/models/Label.js.map +1 -0
  59. package/dist/generated/models/Profile.d.ts +1269 -0
  60. package/dist/generated/models/Profile.js +2 -0
  61. package/dist/generated/models/Profile.js.map +1 -0
  62. package/dist/generated/models/SyncState.d.ts +1130 -0
  63. package/dist/generated/models/SyncState.js +2 -0
  64. package/dist/generated/models/SyncState.js.map +1 -0
  65. package/dist/generated/models/Thread.d.ts +1608 -0
  66. package/dist/generated/models/Thread.js +2 -0
  67. package/dist/generated/models/Thread.js.map +1 -0
  68. package/dist/generated/models.d.ts +6 -9
  69. package/dist/gmail-client.d.ts +119 -94
  70. package/dist/gmail-client.js +862 -322
  71. package/dist/gmail-client.js.map +1 -1
  72. package/dist/mail-tui.d.ts +1 -0
  73. package/dist/mail-tui.js +517 -0
  74. package/dist/mail-tui.js.map +1 -0
  75. package/dist/output.d.ts +6 -0
  76. package/dist/output.js +124 -11
  77. package/dist/output.js.map +1 -1
  78. package/package.json +39 -11
  79. package/schema.prisma +81 -113
  80. package/src/api-utils.ts +103 -5
  81. package/src/auth.ts +224 -143
  82. package/src/calendar-client.ts +196 -89
  83. package/src/cli.ts +30 -1
  84. package/src/commands/attachment.ts +18 -19
  85. package/src/commands/auth-cmd.ts +19 -9
  86. package/src/commands/calendar.ts +42 -85
  87. package/src/commands/draft.ts +19 -22
  88. package/src/commands/label.ts +21 -57
  89. package/src/commands/mail-actions.ts +11 -19
  90. package/src/commands/mail.ts +102 -147
  91. package/src/commands/profile.ts +12 -28
  92. package/src/commands/watch.ts +37 -304
  93. package/src/db.ts +13 -16
  94. package/src/generated/browser.ts +49 -0
  95. package/src/generated/client.ts +71 -0
  96. package/src/generated/commonInputTypes.ts +332 -0
  97. package/src/generated/enums.ts +17 -0
  98. package/src/generated/internal/class.ts +250 -0
  99. package/src/generated/internal/prismaNamespace.ts +1198 -0
  100. package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
  101. package/src/generated/models/Account.ts +1848 -0
  102. package/src/generated/models/CalendarList.ts +1331 -0
  103. package/src/generated/models/Label.ts +1331 -0
  104. package/src/generated/models/Profile.ts +1439 -0
  105. package/src/generated/models/SyncState.ts +1300 -0
  106. package/src/generated/models/Thread.ts +1787 -0
  107. package/src/generated/models.ts +17 -0
  108. package/src/gmail-client.test.ts +59 -0
  109. package/src/gmail-client.ts +1034 -429
  110. package/src/mail-tui.tsx +1061 -0
  111. package/src/output.test.ts +1093 -0
  112. package/src/output.ts +128 -13
  113. package/src/schema.sql +58 -68
  114. package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
  115. package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
  116. package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
  117. package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
  118. package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
  119. package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
  120. package/AGENTS.md +0 -26
  121. package/CHANGELOG.md +0 -43
  122. package/dist/generated/models/accounts.d.ts +0 -2000
  123. package/dist/generated/models/accounts.js +0 -2
  124. package/dist/generated/models/accounts.js.map +0 -1
  125. package/dist/generated/models/calendar_events.d.ts +0 -1433
  126. package/dist/generated/models/calendar_events.js +0 -2
  127. package/dist/generated/models/calendar_events.js.map +0 -1
  128. package/dist/generated/models/calendar_lists.d.ts +0 -1131
  129. package/dist/generated/models/calendar_lists.js +0 -2
  130. package/dist/generated/models/calendar_lists.js.map +0 -1
  131. package/dist/generated/models/label_counts.d.ts +0 -1131
  132. package/dist/generated/models/label_counts.js +0 -2
  133. package/dist/generated/models/label_counts.js.map +0 -1
  134. package/dist/generated/models/labels.d.ts +0 -1131
  135. package/dist/generated/models/labels.js +0 -2
  136. package/dist/generated/models/labels.js.map +0 -1
  137. package/dist/generated/models/profiles.d.ts +0 -1131
  138. package/dist/generated/models/profiles.js +0 -2
  139. package/dist/generated/models/profiles.js.map +0 -1
  140. package/dist/generated/models/sync_states.d.ts +0 -1107
  141. package/dist/generated/models/sync_states.js +0 -2
  142. package/dist/generated/models/sync_states.js.map +0 -1
  143. package/dist/generated/models/thread_lists.d.ts +0 -1404
  144. package/dist/generated/models/thread_lists.js +0 -2
  145. package/dist/generated/models/thread_lists.js.map +0 -1
  146. package/dist/generated/models/threads.d.ts +0 -1247
  147. package/dist/generated/models/threads.js +0 -2
  148. package/dist/generated/models/threads.js.map +0 -1
  149. package/dist/gmail-cache.d.ts +0 -60
  150. package/dist/gmail-cache.js +0 -264
  151. package/dist/gmail-cache.js.map +0 -1
  152. package/docs/gogcli-gmail-implementation.md +0 -599
  153. package/scripts/test-device-code-clients.ts +0 -186
  154. package/scripts/test-micropython-scopes.ts +0 -72
  155. package/scripts/test-oauth-clients.ts +0 -257
  156. package/src/gmail-cache.ts +0 -339
  157. package/tsconfig.json +0 -16
@@ -1,18 +1,22 @@
1
1
  // Gmail API client for CLI/TUI use.
2
2
  // Wraps the @googleapis/gmail SDK with structured methods, object params, and inferred return types.
3
3
  // No abstract interfaces, no RPC layer — just a concrete class for Gmail.
4
- // Ported from Zero's GoogleMailManager (apps/server/src/lib/driver/google.ts) with CLI adaptations:
5
- // - No HTML sanitization (CLI renders text)
6
- // - No Effect library (simple retry loop)
7
- // - Uses batchModify for label mutations (more efficient than per-thread modify)
8
- // - Body decoding inline with Buffer (no base64-js dependency)
9
- // - Concurrent hydration with configurable concurrency limit
4
+ // Cache is built into the client for expensive read paths.
5
+ // List/search entry-point calls always fetch fresh IDs and then reuse per-thread cache
6
+ // to avoid repeated N+1 hydration calls.
7
+ // Raw Google API responses are stored in the cache (gmail_v1.Schema$*) so the cache
8
+ // is resilient to changes in our own parsed types. Parsing happens at read time.
9
+ // When account is not provided (bootstrap/login flow), cache is skipped entirely.
10
10
 
11
11
  import { gmail as gmailApi, type gmail_v1 } from '@googleapis/gmail'
12
12
  import type { OAuth2Client } from 'google-auth-library'
13
13
  import { createMimeMessage } from 'mimetext'
14
14
  import { parseFrom, parseAddressList } from './email-utils.js'
15
- import { withRetry, mapConcurrent } from './api-utils.js'
15
+ import * as errore from 'errore'
16
+ import { withRetry, mapConcurrent, AuthError, isAuthLikeError, ApiError, NotFoundError, EmptyThreadError, MissingDataError } from './api-utils.js'
17
+ import { renderEmailBody } from './output.js'
18
+ import { getPrisma } from './db.js'
19
+ import type { AccountId } from './auth.js'
16
20
 
17
21
  // ---------------------------------------------------------------------------
18
22
  // Types
@@ -42,8 +46,9 @@ export interface ParsedMessage {
42
46
  inReplyTo?: string
43
47
  references?: string
44
48
  listUnsubscribe?: string
45
- body: string // decoded plain text or html
49
+ body: string // decoded body (html preferred for rich rendering)
46
50
  mimeType: string // 'text/plain' or 'text/html'
51
+ textBody: string | null // decoded text/plain body when available (for reply parsing)
47
52
  attachments: AttachmentMeta[]
48
53
  }
49
54
 
@@ -81,10 +86,29 @@ export interface ThreadListItem {
81
86
 
82
87
  export interface ThreadListResult {
83
88
  threads: ThreadListItem[]
89
+ /** Raw Google gmail_v1.Schema$Thread metadata responses, parallel to threads[]. */
90
+ rawThreads: gmail_v1.Schema$Thread[]
84
91
  nextPageToken: string | null
85
92
  resultSizeEstimate: number
86
93
  }
87
94
 
95
+ /** Result from getThread() — includes both parsed data and the raw Google response. */
96
+ export interface ThreadResult {
97
+ parsed: ThreadData
98
+ raw: gmail_v1.Schema$Thread
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Watch event type
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export interface WatchEvent {
106
+ account: AccountId
107
+ type: 'new_message'
108
+ message: ParsedMessage
109
+ threadId: string
110
+ }
111
+
88
112
  // ---------------------------------------------------------------------------
89
113
  // Constants
90
114
  // ---------------------------------------------------------------------------
@@ -115,6 +139,40 @@ function decodeBase64Url(encoded: string) {
115
139
  return Buffer.from(base64, 'base64').toString('utf-8')
116
140
  }
117
141
 
142
+ function decodeHtmlEntities(value: string): string {
143
+ const namedEntities: Record<string, string> = {
144
+ amp: '&',
145
+ lt: '<',
146
+ gt: '>',
147
+ quot: '"',
148
+ apos: "'",
149
+ nbsp: ' ',
150
+ }
151
+
152
+ return value.replace(/&(#x[0-9a-fA-F]+|#\d+|[a-zA-Z]+);/g, (match, entity: string) => {
153
+ if (entity.startsWith('#x') || entity.startsWith('#X')) {
154
+ const codePoint = Number.parseInt(entity.slice(2), 16)
155
+ if (!Number.isFinite(codePoint)) return match
156
+ return String.fromCodePoint(codePoint)
157
+ }
158
+
159
+ if (entity.startsWith('#')) {
160
+ const codePoint = Number.parseInt(entity.slice(1), 10)
161
+ if (!Number.isFinite(codePoint)) return match
162
+ return String.fromCodePoint(codePoint)
163
+ }
164
+
165
+ return namedEntities[entity.toLowerCase()] ?? match
166
+ })
167
+ }
168
+
169
+ function sanitizeSnippet(snippet: string): string {
170
+ return decodeHtmlEntities(snippet)
171
+ .replace(/[\u00A0\u00AD\u034F\u061C\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, ' ')
172
+ .replace(/\s+/g, ' ')
173
+ .trim()
174
+ }
175
+
118
176
  function encodeBase64Url(data: string | Buffer) {
119
177
  const buf = typeof data === 'string' ? Buffer.from(data) : data
120
178
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
@@ -124,12 +182,134 @@ function encodeBase64Url(data: string | Buffer) {
124
182
  // GmailClient
125
183
  // ---------------------------------------------------------------------------
126
184
 
185
+ // TTL constants in milliseconds
186
+ const TTL = {
187
+ THREAD: 30 * 60 * 1000, // 30 minutes
188
+ LABELS: 30 * 60 * 1000, // 30 minutes
189
+ PROFILE: 24 * 60 * 60 * 1000, // 24 hours
190
+ } as const
191
+
192
+ function isExpired(createdAt: Date, ttlMs: number): boolean {
193
+ return createdAt.getTime() + ttlMs < Date.now()
194
+ }
195
+
196
+ /** Boundary helper: wrap a googleapis SDK call, converting auth-like errors to AuthError values.
197
+ * Non-auth errors are wrapped in ApiError so they remain error values (no throwing).
198
+ * Original error is preserved as `cause` for debugging. */
199
+ function gmailBoundary<T>(email: string, fn: () => Promise<T>) {
200
+ return errore.tryAsync({
201
+ try: fn,
202
+ catch: (err) => isAuthLikeError(err)
203
+ ? new AuthError({ email, reason: String(err) })
204
+ : new ApiError({ reason: String(err), cause: err }),
205
+ })
206
+ }
207
+
127
208
  export class GmailClient {
128
209
  private gmail: gmail_v1.Gmail
129
210
  private labelIdCache: Record<string, string> = {}
211
+ private account: AccountId | null
130
212
 
131
- constructor({ auth }: { auth: OAuth2Client }) {
213
+ constructor({ auth, account }: { auth: OAuth2Client; account?: AccountId }) {
132
214
  this.gmail = gmailApi({ version: 'v1', auth })
215
+ this.account = account ?? null
216
+ }
217
+
218
+ // =========================================================================
219
+ // Cache helpers (private) — skip all cache ops when account is null
220
+ // =========================================================================
221
+
222
+ private get cacheEnabled(): boolean {
223
+ return this.account !== null
224
+ }
225
+
226
+ private async getCachedThread(threadId: string): Promise<gmail_v1.Schema$Thread | undefined> {
227
+ if (!this.cacheEnabled) return undefined
228
+ const prisma = await getPrisma()
229
+ const row = await prisma.thread.findUnique({
230
+ where: { email_appId_threadId: { email: this.account!.email, appId: this.account!.appId, threadId } },
231
+ })
232
+ if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
233
+ return JSON.parse(row.rawData) as gmail_v1.Schema$Thread
234
+ }
235
+
236
+ private async cacheThreadData(threadId: string, raw: gmail_v1.Schema$Thread, parsed: ThreadData): Promise<void> {
237
+ if (!this.cacheEnabled) return
238
+ const prisma = await getPrisma()
239
+ await prisma.thread.upsert({
240
+ where: { email_appId_threadId: { email: this.account!.email, appId: this.account!.appId, threadId } },
241
+ create: {
242
+ email: this.account!.email, appId: this.account!.appId, threadId,
243
+ subject: parsed.subject, snippet: parsed.snippet,
244
+ fromEmail: parsed.from.email, fromName: parsed.from.name ?? '',
245
+ date: parsed.date, labelIds: parsed.labelIds.join(','),
246
+ hasUnread: parsed.hasUnread, msgCount: parsed.messageCount,
247
+ historyId: parsed.historyId,
248
+ rawData: JSON.stringify(raw), ttlMs: TTL.THREAD, createdAt: new Date(),
249
+ },
250
+ update: {
251
+ subject: parsed.subject, snippet: parsed.snippet,
252
+ fromEmail: parsed.from.email, fromName: parsed.from.name ?? '',
253
+ date: parsed.date, labelIds: parsed.labelIds.join(','),
254
+ hasUnread: parsed.hasUnread, msgCount: parsed.messageCount,
255
+ historyId: parsed.historyId,
256
+ rawData: JSON.stringify(raw), ttlMs: TTL.THREAD, createdAt: new Date(),
257
+ },
258
+ })
259
+ }
260
+
261
+ async invalidateThreads(threadIds: string[]): Promise<void> {
262
+ if (!this.cacheEnabled) return
263
+ const prisma = await getPrisma()
264
+ await prisma.thread.deleteMany({ where: { email: this.account!.email, appId: this.account!.appId, threadId: { in: threadIds } } })
265
+ }
266
+
267
+ async invalidateThread(threadId: string): Promise<void> {
268
+ if (!this.cacheEnabled) return
269
+ const prisma = await getPrisma()
270
+ await prisma.thread.deleteMany({ where: { email: this.account!.email, appId: this.account!.appId, threadId } })
271
+ }
272
+
273
+ private async getCachedLabels(): Promise<gmail_v1.Schema$Label[] | undefined> {
274
+ if (!this.cacheEnabled) return undefined
275
+ const prisma = await getPrisma()
276
+ const row = await prisma.label.findUnique({ where: { email_appId: { email: this.account!.email, appId: this.account!.appId } } })
277
+ if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
278
+ return JSON.parse(row.rawData) as gmail_v1.Schema$Label[]
279
+ }
280
+
281
+ private async cacheLabelsData(raw: gmail_v1.Schema$Label[]): Promise<void> {
282
+ if (!this.cacheEnabled) return
283
+ const prisma = await getPrisma()
284
+ await prisma.label.upsert({
285
+ where: { email_appId: { email: this.account!.email, appId: this.account!.appId } },
286
+ create: { email: this.account!.email, appId: this.account!.appId, rawData: JSON.stringify(raw), ttlMs: TTL.LABELS, createdAt: new Date() },
287
+ update: { rawData: JSON.stringify(raw), ttlMs: TTL.LABELS, createdAt: new Date() },
288
+ })
289
+ }
290
+
291
+ async invalidateLabels(): Promise<void> {
292
+ if (!this.cacheEnabled) return
293
+ const prisma = await getPrisma()
294
+ await prisma.label.deleteMany({ where: { email: this.account!.email, appId: this.account!.appId } })
295
+ }
296
+
297
+ private async getCachedProfile(): Promise<{ emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string } | undefined> {
298
+ if (!this.cacheEnabled) return undefined
299
+ const prisma = await getPrisma()
300
+ const row = await prisma.profile.findUnique({ where: { email_appId: { email: this.account!.email, appId: this.account!.appId } } })
301
+ if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
302
+ return { emailAddress: row.emailAddress, messagesTotal: row.messagesTotal, threadsTotal: row.threadsTotal, historyId: row.historyId }
303
+ }
304
+
305
+ private async cacheProfileData(profile: { emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string }): Promise<void> {
306
+ if (!this.cacheEnabled) return
307
+ const prisma = await getPrisma()
308
+ await prisma.profile.upsert({
309
+ where: { email_appId: { email: this.account!.email, appId: this.account!.appId } },
310
+ create: { email: this.account!.email, appId: this.account!.appId, ...profile, ttlMs: TTL.PROFILE, createdAt: new Date() },
311
+ update: { ...profile, ttlMs: TTL.PROFILE, createdAt: new Date() },
312
+ })
133
313
  }
134
314
 
135
315
  // =========================================================================
@@ -148,47 +328,78 @@ export class GmailClient {
148
328
  maxResults?: number
149
329
  labelIds?: string[]
150
330
  pageToken?: string
151
- } = {}): Promise<ThreadListResult> {
331
+ } = {}): Promise<ThreadListResult | AuthError | ApiError> {
152
332
  const { q, resolvedLabelIds } = this.buildSearchParams(folder, query, labelIds)
153
333
 
154
- const res = await withRetry(() =>
155
- this.gmail.users.threads.list({
156
- userId: 'me',
157
- q: q || undefined,
158
- labelIds: resolvedLabelIds.length > 0 ? resolvedLabelIds : undefined,
159
- maxResults,
160
- pageToken: pageToken || undefined,
161
- }),
334
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
335
+ withRetry(() =>
336
+ this.gmail.users.threads.list({
337
+ userId: 'me',
338
+ q: q || undefined,
339
+ labelIds: resolvedLabelIds.length > 0 ? resolvedLabelIds : undefined,
340
+ maxResults,
341
+ pageToken: pageToken || undefined,
342
+ }),
343
+ ),
162
344
  )
345
+ if (res instanceof Error) return res
163
346
 
164
347
  const rawThreads = res.data.threads ?? []
165
348
 
166
- // Hydrate with metadata
167
- const threads = await mapConcurrent(rawThreads, async (t) => {
349
+ // Hydrate with metadata — collect both raw and parsed
350
+ const hydrated = await mapConcurrent(rawThreads, async (t) => {
168
351
  if (!t.id) return null
169
- try {
170
- const detail = await withRetry(() =>
352
+
353
+ const cached = await this.getCachedThread(t.id)
354
+ if (cached && (!t.historyId || !cached.historyId || t.historyId === cached.historyId)) {
355
+ return {
356
+ parsed: GmailClient.parseRawThreadListItem(cached),
357
+ raw: cached,
358
+ }
359
+ }
360
+
361
+ // Boundary: threads.get — auth errors abort via mapConcurrent, others skip.
362
+ const detail = await gmailBoundary(this.account?.email ?? 'unknown', () =>
363
+ withRetry(() =>
171
364
  this.gmail.users.threads.get({
172
365
  userId: 'me',
173
366
  id: t.id!,
174
- format: 'metadata',
175
- metadataHeaders: ['Subject', 'From', 'Date', 'To'],
367
+ format: 'full',
176
368
  }),
177
- )
178
- return this.parseThreadListItem(t.id, detail.data)
179
- } catch {
180
- return null
369
+ ),
370
+ )
371
+ if (detail instanceof AuthError) return detail
372
+ if (detail instanceof Error) return null
373
+
374
+ const parsed = GmailClient.parseRawThread(detail.data)
375
+ await this.cacheThreadData(t.id, detail.data, parsed)
376
+
377
+ return {
378
+ parsed: GmailClient.parseRawThreadListItem(detail.data),
379
+ raw: detail.data,
181
380
  }
182
381
  })
183
382
 
184
- return {
185
- threads: threads.filter((t): t is ThreadListItem => t !== null),
383
+ if (hydrated instanceof Error) return hydrated
384
+
385
+ const valid = hydrated.filter((t): t is NonNullable<typeof t> => t !== null)
386
+ const result: ThreadListResult = {
387
+ threads: valid.map((t) => t.parsed),
388
+ rawThreads: valid.map((t) => t.raw),
186
389
  nextPageToken: res.data.nextPageToken ?? null,
187
390
  resultSizeEstimate: res.data.resultSizeEstimate ?? 0,
188
391
  }
392
+
393
+ return result
189
394
  }
190
395
 
191
- async getThread({ threadId }: { threadId: string }) {
396
+ async getThread({ threadId }: { threadId: string }): Promise<ThreadResult> {
397
+ // Check cache
398
+ const cached = await this.getCachedThread(threadId)
399
+ if (cached) {
400
+ return { parsed: GmailClient.parseRawThread(cached), raw: cached }
401
+ }
402
+
192
403
  const res = await withRetry(() =>
193
404
  this.gmail.users.threads.get({
194
405
  userId: 'me',
@@ -197,37 +408,13 @@ export class GmailClient {
197
408
  }),
198
409
  )
199
410
 
200
- if (!res.data.messages || res.data.messages.length === 0) {
201
- return {
202
- id: threadId,
203
- historyId: res.data.historyId ?? null,
204
- messages: [],
205
- subject: '',
206
- snippet: '',
207
- from: { email: '' },
208
- date: '',
209
- labelIds: [],
210
- hasUnread: false,
211
- messageCount: 0,
212
- } satisfies ThreadData
213
- }
411
+ const parsed = GmailClient.parseRawThread(res.data)
412
+ const result: ThreadResult = { parsed, raw: res.data }
214
413
 
215
- const messages = res.data.messages.map((m) => this.parseMessage(m))
216
- const latest = messages.findLast((m) => !m.isDraft) ?? messages[messages.length - 1]!
217
- const allLabels = [...new Set(messages.flatMap((m) => m.labelIds))]
414
+ // Write cache
415
+ await this.cacheThreadData(threadId, res.data, parsed)
218
416
 
219
- return {
220
- id: threadId,
221
- historyId: res.data.historyId ?? null,
222
- messages,
223
- subject: latest.subject,
224
- snippet: latest.snippet,
225
- from: latest.from,
226
- date: latest.date,
227
- labelIds: allLabels,
228
- hasUnread: messages.some((m) => m.unread),
229
- messageCount: messages.filter((m) => !m.isDraft).length,
230
- } satisfies ThreadData
417
+ return result
231
418
  }
232
419
 
233
420
  async getMessage({
@@ -236,14 +423,17 @@ export class GmailClient {
236
423
  }: {
237
424
  messageId: string
238
425
  format?: 'full' | 'metadata' | 'minimal' | 'raw'
239
- }) {
240
- const res = await withRetry(() =>
241
- this.gmail.users.messages.get({
242
- userId: 'me',
243
- id: messageId,
244
- format,
245
- }),
426
+ }): Promise<ParsedMessage | { id: string; raw: string } | AuthError | ApiError> {
427
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
428
+ withRetry(() =>
429
+ this.gmail.users.messages.get({
430
+ userId: 'me',
431
+ id: messageId,
432
+ format,
433
+ }),
434
+ ),
246
435
  )
436
+ if (res instanceof Error) return res
247
437
 
248
438
  if (format === 'raw') {
249
439
  return {
@@ -255,16 +445,19 @@ export class GmailClient {
255
445
  return this.parseMessage(res.data)
256
446
  }
257
447
 
258
- async getRawMessage({ messageId }: { messageId: string }) {
259
- const res = await withRetry(() =>
260
- this.gmail.users.messages.get({
261
- userId: 'me',
262
- id: messageId,
263
- format: 'raw',
264
- }),
448
+ async getRawMessage({ messageId }: { messageId: string }): Promise<string | MissingDataError | AuthError | ApiError> {
449
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
450
+ withRetry(() =>
451
+ this.gmail.users.messages.get({
452
+ userId: 'me',
453
+ id: messageId,
454
+ format: 'raw',
455
+ }),
456
+ ),
265
457
  )
458
+ if (res instanceof Error) return res
266
459
 
267
- if (!res.data.raw) throw new Error('No raw email data found')
460
+ if (!res.data.raw) return new MissingDataError({ what: 'raw email data', resource: `message ${messageId}` })
268
461
  return decodeBase64Url(res.data.raw)
269
462
  }
270
463
 
@@ -321,44 +514,128 @@ export class GmailClient {
321
514
  }
322
515
 
323
516
  // =========================================================================
324
- // Draft operations
517
+ // Reply / Forward (high-level composition)
325
518
  // =========================================================================
326
519
 
327
- async createDraft({
328
- to,
329
- subject,
520
+ /**
521
+ * Reply to a thread. Handles reply-to resolution, reply-all CC computation,
522
+ * References/In-Reply-To headers, and subject prefixing.
523
+ */
524
+ async replyToThread({
525
+ threadId,
330
526
  body,
527
+ replyAll = false,
331
528
  cc,
332
- bcc,
333
- threadId,
334
529
  fromEmail,
335
- attachments,
336
530
  }: {
337
- to: Array<{ name?: string; email: string }>
338
- subject: string
531
+ threadId: string
339
532
  body: string
340
- cc?: Array<{ name?: string; email: string }>
341
- bcc?: Array<{ name?: string; email: string }>
342
- threadId?: string
533
+ replyAll?: boolean
534
+ cc?: Array<{ email: string }>
343
535
  fromEmail?: string
344
- attachments?: Array<{ filename: string; mimeType: string; content: Buffer }>
345
- }) {
346
- const raw = this.buildMimeMessage({ to, subject, body, cc, bcc, attachments, fromEmail })
536
+ }): Promise<EmptyThreadError | AuthError | ApiError | gmail_v1.Schema$Message> {
537
+ const { parsed: thread } = await this.getThread({ threadId })
538
+ if (thread.messages.length === 0) {
539
+ return new EmptyThreadError({ threadId })
540
+ }
347
541
 
348
- const res = await withRetry(() =>
349
- this.gmail.users.drafts.create({
350
- userId: 'me',
351
- requestBody: {
352
- message: { raw, threadId },
353
- },
354
- }),
355
- )
542
+ const lastMsg = thread.messages[thread.messages.length - 1]!
356
543
 
357
- return res.data
544
+ const replyTo = lastMsg.replyTo ?? lastMsg.from.email
545
+ const to = [{ email: replyTo }]
546
+
547
+ let resolvedCc: Array<{ email: string }> | undefined
548
+ if (replyAll) {
549
+ const profile = await this.getProfile()
550
+ if (profile instanceof Error) return profile
551
+ const myEmail = profile.emailAddress.toLowerCase()
552
+
553
+ const allRecipients = [
554
+ ...lastMsg.to.map((r) => r.email),
555
+ ...(lastMsg.cc?.map((r) => r.email) ?? []),
556
+ ]
557
+ .filter((e) => e.toLowerCase() !== myEmail)
558
+ .filter((e) => e.toLowerCase() !== replyTo.toLowerCase())
559
+
560
+ if (allRecipients.length > 0) {
561
+ resolvedCc = allRecipients.map((e) => ({ email: e }))
562
+ }
563
+ }
564
+
565
+ if (cc) {
566
+ resolvedCc = [...(resolvedCc ?? []), ...cc]
567
+ }
568
+
569
+ const refs = [lastMsg.references, lastMsg.messageId].filter(Boolean).join(' ')
570
+
571
+ const result = await this.sendMessage({
572
+ to,
573
+ subject: lastMsg.subject.startsWith('Re:') ? lastMsg.subject : `Re: ${lastMsg.subject}`,
574
+ body,
575
+ cc: resolvedCc,
576
+ threadId,
577
+ inReplyTo: lastMsg.messageId,
578
+ references: refs || undefined,
579
+ fromEmail,
580
+ })
581
+
582
+ await this.invalidateThread(threadId)
583
+
584
+ return result
585
+ }
586
+
587
+ /**
588
+ * Forward a thread. Fetches the last message, renders its body,
589
+ * builds the "Forwarded message" block, and sends.
590
+ */
591
+ async forwardThread({
592
+ threadId,
593
+ to,
594
+ body,
595
+ fromEmail,
596
+ }: {
597
+ threadId: string
598
+ to: Array<{ email: string }>
599
+ body?: string
600
+ fromEmail?: string
601
+ }): Promise<EmptyThreadError | gmail_v1.Schema$Message> {
602
+ const { parsed: thread } = await this.getThread({ threadId })
603
+ if (thread.messages.length === 0) {
604
+ return new EmptyThreadError({ threadId })
605
+ }
606
+
607
+ const lastMsg = thread.messages[thread.messages.length - 1]!
608
+ const renderedBody = renderEmailBody(lastMsg.body, lastMsg.mimeType)
609
+
610
+ const fromStr = lastMsg.from.name && lastMsg.from.name !== lastMsg.from.email
611
+ ? `${lastMsg.from.name} <${lastMsg.from.email}>`
612
+ : lastMsg.from.email
613
+
614
+ const fullBody = [
615
+ body ?? '',
616
+ '',
617
+ '---------- Forwarded message ----------',
618
+ `From: ${fromStr}`,
619
+ `Date: ${lastMsg.date}`,
620
+ `Subject: ${lastMsg.subject}`,
621
+ `To: ${lastMsg.to.map((t) => t.email).join(', ')}`,
622
+ '',
623
+ renderedBody,
624
+ ].join('\n')
625
+
626
+ return this.sendMessage({
627
+ to,
628
+ subject: `Fwd: ${lastMsg.subject}`,
629
+ body: fullBody,
630
+ fromEmail,
631
+ })
358
632
  }
359
633
 
360
- async updateDraft({
361
- draftId,
634
+ // =========================================================================
635
+ // Draft operations
636
+ // =========================================================================
637
+
638
+ async createDraft({
362
639
  to,
363
640
  subject,
364
641
  body,
@@ -368,7 +645,6 @@ export class GmailClient {
368
645
  fromEmail,
369
646
  attachments,
370
647
  }: {
371
- draftId: string
372
648
  to: Array<{ name?: string; email: string }>
373
649
  subject: string
374
650
  body: string
@@ -381,9 +657,8 @@ export class GmailClient {
381
657
  const raw = this.buildMimeMessage({ to, subject, body, cc, bcc, attachments, fromEmail })
382
658
 
383
659
  const res = await withRetry(() =>
384
- this.gmail.users.drafts.update({
660
+ this.gmail.users.drafts.create({
385
661
  userId: 'me',
386
- id: draftId,
387
662
  requestBody: {
388
663
  message: { raw, threadId },
389
664
  },
@@ -393,16 +668,19 @@ export class GmailClient {
393
668
  return res.data
394
669
  }
395
670
 
396
- async getDraft({ draftId }: { draftId: string }) {
397
- const res = await withRetry(() =>
398
- this.gmail.users.drafts.get({
399
- userId: 'me',
400
- id: draftId,
401
- format: 'full',
402
- }),
671
+ async getDraft({ draftId }: { draftId: string }): Promise<NotFoundError | AuthError | ApiError | { id: string; message: ParsedMessage; to: string[]; cc: string[]; bcc: string[] }> {
672
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
673
+ withRetry(() =>
674
+ this.gmail.users.drafts.get({
675
+ userId: 'me',
676
+ id: draftId,
677
+ format: 'full',
678
+ }),
679
+ ),
403
680
  )
681
+ if (res instanceof Error) return res
404
682
 
405
- if (!res.data || !res.data.message) throw new Error('Draft not found')
683
+ if (!res.data || !res.data.message) return new NotFoundError({ resource: `draft ${draftId}` })
406
684
 
407
685
  const message = this.parseMessage(res.data.message)
408
686
  const headers = res.data.message.payload?.headers ?? []
@@ -424,41 +702,47 @@ export class GmailClient {
424
702
  query?: string
425
703
  maxResults?: number
426
704
  pageToken?: string
427
- } = {}) {
428
- const res = await withRetry(() =>
429
- this.gmail.users.drafts.list({
430
- userId: 'me',
431
- q: query || undefined,
432
- maxResults,
433
- pageToken: pageToken || undefined,
434
- }),
705
+ } = {}): Promise<{ drafts: Array<{ id: string; threadId: string | null; subject: string; to: string[]; date: string; snippet: string }>; nextPageToken: string | null } | AuthError | ApiError> {
706
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
707
+ withRetry(() =>
708
+ this.gmail.users.drafts.list({
709
+ userId: 'me',
710
+ q: query || undefined,
711
+ maxResults,
712
+ pageToken: pageToken || undefined,
713
+ }),
714
+ ),
435
715
  )
716
+ if (res instanceof Error) return res
436
717
 
437
718
  const drafts = await mapConcurrent(res.data.drafts ?? [], async (draft) => {
438
719
  if (!draft.id) return null
439
- try {
440
- const detail = await withRetry(() =>
720
+ // Boundary: drafts.get — auth errors abort via mapConcurrent, others skip.
721
+ const detail = await gmailBoundary(this.account?.email ?? 'unknown', () =>
722
+ withRetry(() =>
441
723
  this.gmail.users.drafts.get({
442
724
  userId: 'me',
443
725
  id: draft.id!,
444
726
  format: 'metadata',
445
727
  }),
446
- )
447
- if (!detail.data.message) return null
448
- const headers = detail.data.message.payload?.headers ?? []
449
- return {
450
- id: draft.id,
451
- threadId: detail.data.message.threadId ?? null,
452
- subject: this.getHeader(headers, 'subject') ?? '(no subject)',
453
- to: this.getHeaderValues(headers, 'to'),
454
- date: this.getHeader(headers, 'date') ?? '',
455
- snippet: detail.data.message.snippet ?? '',
456
- }
457
- } catch {
458
- return null
728
+ ),
729
+ )
730
+ if (detail instanceof AuthError) return detail
731
+ if (detail instanceof Error) return null
732
+ if (!detail.data.message) return null
733
+ const headers = detail.data.message.payload?.headers ?? []
734
+ return {
735
+ id: draft.id,
736
+ threadId: detail.data.message.threadId ?? null,
737
+ subject: this.getHeader(headers, 'subject') ?? '(no subject)',
738
+ to: this.getHeaderValues(headers, 'to'),
739
+ date: this.getHeader(headers, 'date') ?? '',
740
+ snippet: detail.data.message.snippet ?? '',
459
741
  }
460
742
  })
461
743
 
744
+ if (drafts instanceof Error) return drafts
745
+
462
746
  return {
463
747
  drafts: drafts.filter((d): d is NonNullable<typeof d> => d !== null),
464
748
  nextPageToken: res.data.nextPageToken ?? null,
@@ -489,34 +773,42 @@ export class GmailClient {
489
773
  // Label mutations (read/unread, star, archive, trash, labels)
490
774
  // =========================================================================
491
775
 
492
- async markAsRead({ threadIds }: { threadIds: string[] }) {
776
+ async markAsRead({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
493
777
  const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
494
778
  labelIds.includes('UNREAD'),
495
779
  )
780
+ if (messageIds instanceof Error) return messageIds
496
781
  if (messageIds.length === 0) return
497
782
  await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
783
+ await this.invalidateAfterThreadMutation(threadIds)
498
784
  }
499
785
 
500
- async markAsUnread({ threadIds }: { threadIds: string[] }) {
786
+ async markAsUnread({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
501
787
  const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
502
788
  !labelIds.includes('UNREAD'),
503
789
  )
790
+ if (messageIds instanceof Error) return messageIds
504
791
  if (messageIds.length === 0) return
505
792
  await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
793
+ await this.invalidateAfterThreadMutation(threadIds)
506
794
  }
507
795
 
508
- async star({ threadIds }: { threadIds: string[] }) {
796
+ async star({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
509
797
  const messageIds = await this.getMessageIdsForThreads(threadIds)
798
+ if (messageIds instanceof Error) return messageIds
510
799
  if (messageIds.length === 0) return
511
800
  await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
801
+ await this.invalidateAfterThreadMutation(threadIds)
512
802
  }
513
803
 
514
- async unstar({ threadIds }: { threadIds: string[] }) {
804
+ async unstar({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
515
805
  const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
516
806
  labelIds.includes('STARRED'),
517
807
  )
808
+ if (messageIds instanceof Error) return messageIds
518
809
  if (messageIds.length === 0) return
519
810
  await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
811
+ await this.invalidateAfterThreadMutation(threadIds)
520
812
  }
521
813
 
522
814
  async modifyLabels({
@@ -527,20 +819,26 @@ export class GmailClient {
527
819
  threadIds: string[]
528
820
  addLabelIds?: string[]
529
821
  removeLabelIds?: string[]
530
- }) {
822
+ }): Promise<void | AuthError | ApiError> {
531
823
  // Resolve add labels (auto-create if missing), but only look up remove labels (never create)
532
824
  const resolvedAdd = await Promise.all(addLabelIds.map((l) => this.resolveLabelId(l)))
533
- const resolvedRemove = (
534
- await Promise.all(removeLabelIds.map((l) => this.lookupLabelId(l)))
535
- ).filter((id): id is string => id !== null)
825
+ const addErr = resolvedAdd.find((r): r is AuthError | ApiError => r instanceof Error)
826
+ if (addErr) return addErr
827
+
828
+ const resolvedRemoveRaw = await Promise.all(removeLabelIds.map((l) => this.lookupLabelId(l)))
829
+ const removeErr = resolvedRemoveRaw.find((r): r is AuthError | ApiError => r instanceof Error)
830
+ if (removeErr) return removeErr
831
+ const resolvedRemove = resolvedRemoveRaw.filter((id): id is string => typeof id === 'string')
536
832
 
537
833
  const messageIds = await this.getMessageIdsForThreads(threadIds)
834
+ if (messageIds instanceof Error) return messageIds
538
835
  if (messageIds.length === 0) return
539
836
 
540
837
  await this.batchModifyMessages(messageIds, {
541
- addLabelIds: resolvedAdd,
838
+ addLabelIds: resolvedAdd.filter((r): r is string => typeof r === 'string'),
542
839
  removeLabelIds: resolvedRemove,
543
840
  })
841
+ await this.invalidateAfterThreadMutation(threadIds)
544
842
  }
545
843
 
546
844
  async trash({ threadId }: { threadId: string }) {
@@ -550,6 +848,7 @@ export class GmailClient {
550
848
  id: threadId,
551
849
  }),
552
850
  )
851
+ await this.invalidateAfterThreadMutation([threadId])
553
852
  }
554
853
 
555
854
  async untrash({ threadId }: { threadId: string }) {
@@ -559,25 +858,24 @@ export class GmailClient {
559
858
  id: threadId,
560
859
  }),
561
860
  )
861
+ await this.invalidateAfterThreadMutation([threadId])
562
862
  }
563
863
 
564
- async archive({ threadIds }: { threadIds: string[] }) {
864
+ async archive({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
565
865
  const messageIds = await this.getMessageIdsForThreads(threadIds)
866
+ if (messageIds instanceof Error) return messageIds
566
867
  if (messageIds.length === 0) return
567
868
  await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
869
+ await this.invalidateAfterThreadMutation(threadIds)
568
870
  }
569
871
 
570
- async deleteMessage({ messageId }: { messageId: string }) {
571
- await withRetry(() =>
572
- this.gmail.users.messages.delete({
573
- userId: 'me',
574
- id: messageId,
575
- }),
576
- )
872
+ /** Invalidate thread cache after a thread mutation. */
873
+ private async invalidateAfterThreadMutation(threadIds: string[]): Promise<void> {
874
+ await this.invalidateThreads(threadIds)
577
875
  }
578
876
 
579
877
  /** Moves all spam threads to trash. Does not permanently delete. */
580
- async trashAllSpam() {
878
+ async trashAllSpam(): Promise<{ count: number } | AuthError | ApiError> {
581
879
  let totalDeleted = 0
582
880
  let pageToken: string | undefined
583
881
 
@@ -587,11 +885,13 @@ export class GmailClient {
587
885
  maxResults: 100,
588
886
  pageToken,
589
887
  })
888
+ if (res instanceof Error) return res
590
889
 
591
890
  if (res.threads.length === 0) break
592
891
 
593
892
  const threadIds = res.threads.map((t) => t.id)
594
893
  const messageIds = await this.getMessageIdsForThreads(threadIds)
894
+ if (messageIds instanceof Error) return messageIds
595
895
  await this.batchModifyMessages(messageIds, {
596
896
  addLabelIds: ['TRASH'],
597
897
  removeLabelIds: ['SPAM', 'INBOX'],
@@ -609,26 +909,26 @@ export class GmailClient {
609
909
  // Labels CRUD
610
910
  // =========================================================================
611
911
 
612
- async listLabels() {
613
- const res = await withRetry(() =>
614
- this.gmail.users.labels.list({ userId: 'me' }),
615
- )
912
+ async listLabels(): Promise<{ parsed: ReturnType<typeof GmailClient.parseRawLabels>; raw: gmail_v1.Schema$Label[] } | AuthError | ApiError> {
913
+ // Check cache
914
+ const cached = await this.getCachedLabels()
915
+ if (cached) {
916
+ return { parsed: GmailClient.parseRawLabels(cached), raw: cached }
917
+ }
616
918
 
617
- return (
618
- res.data.labels?.map((label) => ({
619
- id: label.id ?? '',
620
- name: label.name ?? '',
621
- type: (label.type ?? 'user') as 'system' | 'user',
622
- messageListVisibility: label.messageListVisibility ?? null,
623
- labelListVisibility: label.labelListVisibility ?? null,
624
- color: label.color
625
- ? {
626
- backgroundColor: label.color.backgroundColor ?? '',
627
- textColor: label.color.textColor ?? '',
628
- }
629
- : null,
630
- })) ?? []
919
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
920
+ withRetry(() =>
921
+ this.gmail.users.labels.list({ userId: 'me' }),
922
+ ),
631
923
  )
924
+ if (res instanceof Error) return res
925
+
926
+ const rawLabels = res.data.labels ?? []
927
+
928
+ // Write cache
929
+ await this.cacheLabelsData(rawLabels)
930
+
931
+ return { parsed: GmailClient.parseRawLabels(rawLabels), raw: rawLabels }
632
932
  }
633
933
 
634
934
  async getLabel({ labelId }: { labelId: string }) {
@@ -675,35 +975,14 @@ export class GmailClient {
675
975
  }),
676
976
  )
677
977
 
978
+ await this.invalidateLabels()
979
+
678
980
  return {
679
981
  id: res.data.id ?? '',
680
982
  name: res.data.name ?? name,
681
983
  }
682
984
  }
683
985
 
684
- async updateLabel({
685
- labelId,
686
- name,
687
- color,
688
- }: {
689
- labelId: string
690
- name?: string
691
- color?: { backgroundColor: string; textColor: string }
692
- }) {
693
- await withRetry(() =>
694
- this.gmail.users.labels.update({
695
- userId: 'me',
696
- id: labelId,
697
- requestBody: {
698
- name,
699
- color,
700
- },
701
- }),
702
- )
703
- // Invalidate label ID cache since name may have changed
704
- this.labelIdCache = {}
705
- }
706
-
707
986
  async deleteLabel({ labelId }: { labelId: string }) {
708
987
  await withRetry(() =>
709
988
  this.gmail.users.labels.delete({
@@ -711,58 +990,71 @@ export class GmailClient {
711
990
  id: labelId,
712
991
  }),
713
992
  )
714
- // Invalidate label ID cache
715
993
  this.labelIdCache = {}
994
+ await this.invalidateLabels()
716
995
  }
717
996
 
718
997
  // =========================================================================
719
998
  // Label counts (unread counts per folder/label)
720
999
  // =========================================================================
721
1000
 
722
- async getLabelCounts() {
723
- // Fetch label counts and archive count concurrently
724
- const [labels, archiveRes] = await Promise.all([
725
- this.listLabels(),
1001
+ async getLabelCounts(): Promise<{ parsed: Array<{ label: string; count: number }>; raw: gmail_v1.Schema$Label[]; archiveEstimate: number | null } | AuthError | ApiError> {
1002
+ // Always fresh: label counts are user-facing live data.
1003
+
1004
+ // Archive count is best-effort — auth errors propagate, others yield null.
1005
+ const archiveRes = await gmailBoundary(this.account?.email ?? 'unknown', () =>
726
1006
  withRetry(() =>
727
1007
  this.gmail.users.threads.list({
728
1008
  userId: 'me',
729
1009
  q: 'in:archive',
730
1010
  maxResults: 1,
731
1011
  }),
732
- ).catch(() => null),
733
- ])
734
-
735
- const counts = await mapConcurrent(labels, async (label) => {
1012
+ ),
1013
+ )
1014
+ if (archiveRes instanceof AuthError) return archiveRes
1015
+ // archiveRes may be ApiError (non-auth failure) archive count unavailable
1016
+ const labelsResult = await this.listLabels()
1017
+ if (labelsResult instanceof Error) return labelsResult
1018
+
1019
+ // Fetch detailed counts for each label — collect both raw and parsed
1020
+ const rawDetails: gmail_v1.Schema$Label[] = []
1021
+ const counts = await mapConcurrent(labelsResult.parsed, async (label) => {
736
1022
  if (!label.id) return null
737
- try {
738
- const detail = await withRetry(() =>
1023
+ // Boundary: labels.get — auth errors abort via mapConcurrent, others skip.
1024
+ const detail = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1025
+ withRetry(() =>
739
1026
  this.gmail.users.labels.get({
740
1027
  userId: 'me',
741
1028
  id: label.id,
742
1029
  }),
743
- )
744
- const labelName = (detail.data.name ?? detail.data.id ?? '').toLowerCase()
745
- const isTotalLabel = labelName === 'draft' || labelName === 'sent'
746
- return {
747
- label: labelName === 'draft' ? 'drafts' : labelName,
748
- count: Number(isTotalLabel ? detail.data.threadsTotal : detail.data.threadsUnread) || 0,
749
- }
750
- } catch {
751
- return null
1030
+ ),
1031
+ )
1032
+ if (detail instanceof AuthError) return detail
1033
+ if (detail instanceof Error) return null
1034
+ rawDetails.push(detail.data)
1035
+ const labelName = (detail.data.name ?? detail.data.id ?? '').toLowerCase()
1036
+ const isTotalLabel = labelName === 'draft' || labelName === 'sent'
1037
+ return {
1038
+ label: labelName === 'draft' ? 'drafts' : labelName,
1039
+ count: Number(isTotalLabel ? detail.data.threadsTotal : detail.data.threadsUnread) || 0,
752
1040
  }
753
1041
  })
754
1042
 
1043
+ if (counts instanceof Error) return counts
1044
+
755
1045
  const result = counts.filter((c): c is NonNullable<typeof c> => c !== null)
756
1046
 
757
1047
  // Add archive count (same as Zero's count() method)
758
- if (archiveRes) {
1048
+ if (!(archiveRes instanceof Error)) {
759
1049
  result.push({
760
1050
  label: 'archive',
761
1051
  count: Number(archiveRes.data.resultSizeEstimate ?? 0),
762
1052
  })
763
1053
  }
764
1054
 
765
- return result
1055
+ const archiveEstimate = !(archiveRes instanceof Error) ? Number(archiveRes.data.resultSizeEstimate ?? 0) : null
1056
+
1057
+ return { parsed: result, raw: rawDetails, archiveEstimate }
766
1058
  }
767
1059
 
768
1060
  // =========================================================================
@@ -789,182 +1081,97 @@ export class GmailClient {
789
1081
  return data.replace(/-/g, '+').replace(/_/g, '/')
790
1082
  }
791
1083
 
792
- async getMessageAttachments({ messageId }: { messageId: string }) {
793
- const res = await withRetry(() =>
794
- this.gmail.users.messages.get({
795
- userId: 'me',
796
- id: messageId,
797
- }),
798
- )
799
-
800
- const parts = res.data.payload?.parts
801
- if (!parts) return []
802
-
803
- const attachmentParts = this.findAttachmentParts(parts)
804
-
805
- const attachments = await mapConcurrent(attachmentParts, async (part) => {
806
- const attId = part.body?.attachmentId
807
- if (!attId) return null
808
- try {
809
- const data = await this.getAttachment({ messageId, attachmentId: attId })
810
- return {
811
- filename: part.filename ?? '',
812
- mimeType: part.mimeType ?? '',
813
- size: Number(part.body?.size ?? 0),
814
- attachmentId: attId,
815
- data,
816
- }
817
- } catch {
818
- return null
819
- }
820
- })
821
-
822
- return attachments.filter((a): a is NonNullable<typeof a> => a !== null)
823
- }
824
-
825
1084
  // =========================================================================
826
1085
  // Account / profile
827
1086
  // =========================================================================
828
1087
 
829
- async getProfile() {
830
- const res = await withRetry(() =>
831
- this.gmail.users.getProfile({ userId: 'me' }),
1088
+ async getProfile(): Promise<{ emailAddress: string; messagesTotal: number; threadsTotal: number; historyId: string } | AuthError | ApiError> {
1089
+ // Check cache
1090
+ const cached = await this.getCachedProfile()
1091
+ if (cached) return cached
1092
+
1093
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1094
+ withRetry(() =>
1095
+ this.gmail.users.getProfile({ userId: 'me' }),
1096
+ ),
832
1097
  )
1098
+ if (res instanceof Error) return res
833
1099
 
834
- return {
1100
+ const profile = {
835
1101
  emailAddress: res.data.emailAddress ?? '',
836
1102
  messagesTotal: res.data.messagesTotal ?? 0,
837
1103
  threadsTotal: res.data.threadsTotal ?? 0,
838
1104
  historyId: res.data.historyId ?? '',
839
1105
  }
840
- }
841
1106
 
842
- async getEmailAliases() {
843
- const profile = await this.getProfile()
844
- const primaryEmail = profile.emailAddress
1107
+ // Write cache
1108
+ await this.cacheProfileData(profile)
845
1109
 
846
- const aliases: Array<{ email: string; name?: string; primary: boolean }> = [
847
- { email: primaryEmail, primary: true },
848
- ]
849
-
850
- try {
851
- const settings = await withRetry(() =>
852
- this.gmail.users.settings.sendAs.list({ userId: 'me' }),
853
- )
854
-
855
- for (const alias of settings.data.sendAs ?? []) {
856
- if (alias.isPrimary && alias.sendAsEmail === primaryEmail) continue
857
- aliases.push({
858
- email: alias.sendAsEmail ?? '',
859
- name: alias.displayName ?? undefined,
860
- primary: alias.isPrimary ?? false,
861
- })
862
- }
863
- } catch {
864
- // sendAs.list may fail if the user doesn't have permission
865
- }
866
-
867
- return aliases
1110
+ return profile
868
1111
  }
869
1112
 
870
1113
  // =========================================================================
871
- // History / sync
1114
+ // Static: parse raw Google API responses (used by cache readers)
872
1115
  // =========================================================================
873
1116
 
874
- async listHistory({
875
- startHistoryId,
876
- labelId,
877
- historyTypes,
878
- }: {
879
- startHistoryId: string
880
- labelId?: string
881
- historyTypes?: Array<'messageAdded' | 'messageDeleted' | 'labelAdded' | 'labelRemoved'>
882
- }) {
883
- const allHistory: gmail_v1.Schema$History[] = []
884
- let pageToken: string | undefined
885
- let latestHistoryId = startHistoryId
886
-
887
- while (true) {
888
- const res = await withRetry(() =>
889
- this.gmail.users.history.list({
890
- userId: 'me',
891
- startHistoryId,
892
- labelId,
893
- historyTypes,
894
- pageToken,
895
- }),
896
- )
1117
+ /** Parse a raw gmail_v1.Schema$Thread (format: full) into ThreadData. */
1118
+ static parseRawThread(raw: gmail_v1.Schema$Thread): ThreadData {
1119
+ const messages = (raw.messages ?? []).map((m) => GmailClient.parseRawMessage(m))
897
1120
 
898
- if (res.data.history) {
899
- allHistory.push(...res.data.history)
1121
+ if (messages.length === 0) {
1122
+ return {
1123
+ id: raw.id ?? '',
1124
+ historyId: raw.historyId ?? null,
1125
+ messages: [],
1126
+ subject: '',
1127
+ snippet: '',
1128
+ from: { email: '' },
1129
+ date: '',
1130
+ labelIds: [],
1131
+ hasUnread: false,
1132
+ messageCount: 0,
900
1133
  }
901
- latestHistoryId = res.data.historyId ?? latestHistoryId
902
-
903
- pageToken = res.data.nextPageToken ?? undefined
904
- if (!pageToken) break
905
- }
906
-
907
- return {
908
- history: allHistory,
909
- historyId: latestHistoryId,
910
1134
  }
911
- }
912
1135
 
913
- // NOTE: This method wraps Gmail's push notification API (users.watch),
914
- // which requires a Google Cloud Pub/Sub topic. Since zele uses borrowed
915
- // OAuth credentials (Thunderbird's client ID), we cannot create Pub/Sub
916
- // resources on their GCP project. Users would need their own GCP project,
917
- // which defeats the zero-config design. The CLI uses History API polling
918
- // instead (see src/commands/watch.ts). This method is kept for potential
919
- // future use if users bring their own GCP credentials.
920
- async watch({
921
- topicName,
922
- labelIds = ['INBOX'],
923
- }: {
924
- topicName: string
925
- labelIds?: string[]
926
- }) {
927
- const res = await withRetry(() =>
928
- this.gmail.users.watch({
929
- userId: 'me',
930
- requestBody: {
931
- topicName,
932
- labelIds,
933
- },
934
- }),
935
- )
1136
+ const latest = messages.findLast((m) => !m.isDraft) ?? messages[messages.length - 1]!
1137
+ const allLabels = [...new Set(messages.flatMap((m) => m.labelIds))]
936
1138
 
937
1139
  return {
938
- historyId: res.data.historyId ?? '',
939
- expiration: res.data.expiration ?? '',
1140
+ id: raw.id ?? '',
1141
+ historyId: raw.historyId ?? null,
1142
+ messages,
1143
+ subject: latest.subject,
1144
+ snippet: latest.snippet,
1145
+ from: latest.from,
1146
+ date: latest.date,
1147
+ labelIds: allLabels,
1148
+ hasUnread: messages.some((m) => m.unread),
1149
+ messageCount: messages.filter((m) => !m.isDraft).length,
940
1150
  }
941
1151
  }
942
1152
 
943
- async stopWatch() {
944
- await withRetry(() =>
945
- this.gmail.users.stop({ userId: 'me' }),
946
- )
947
- }
948
-
949
- // =========================================================================
950
- // Private: message parsing
951
- // =========================================================================
952
-
953
- private parseMessage(message: gmail_v1.Schema$Message): ParsedMessage {
1153
+ /** Parse a raw gmail_v1.Schema$Message into ParsedMessage. */
1154
+ static parseRawMessage(message: gmail_v1.Schema$Message): ParsedMessage {
954
1155
  const headers = message.payload?.headers ?? []
955
1156
  const labelIds = message.labelIds ?? []
956
1157
 
957
- const fromHeader = this.getHeader(headers, 'from') ?? ''
958
- const toHeader = this.getHeader(headers, 'to') ?? ''
959
- const ccHeaders = this.getHeaderAll(headers, 'cc')
1158
+ const getHeader = (name: string) =>
1159
+ headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? null
1160
+
1161
+ const fromHeader = getHeader('from') ?? ''
1162
+ const toHeader = getHeader('to') ?? ''
1163
+ const ccHeaders = headers
1164
+ .filter((h) => h.name?.toLowerCase() === 'cc')
1165
+ .map((h) => h.value ?? '')
1166
+ .filter((v) => v.length > 0)
960
1167
 
961
- const { body, mimeType } = this.extractBody(message.payload ?? {})
1168
+ const { body, mimeType, textBody } = GmailClient.extractBodyStatic(message.payload ?? {})
962
1169
 
963
1170
  return {
964
1171
  id: message.id ?? '',
965
1172
  threadId: message.threadId ?? '',
966
- subject: (this.getHeader(headers, 'subject') ?? '(no subject)').replace(/"/g, '').trim(),
967
- snippet: message.snippet ?? '',
1173
+ subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
1174
+ snippet: sanitizeSnippet(message.snippet ?? ''),
968
1175
  from: parseFrom(fromHeader),
969
1176
  to: toHeader ? parseAddressList(toHeader) : [],
970
1177
  cc:
@@ -972,112 +1179,149 @@ export class GmailClient {
972
1179
  ? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
973
1180
  : null,
974
1181
  bcc: [],
975
- replyTo: this.getHeader(headers, 'reply-to') ?? undefined,
976
- date: this.getHeader(headers, 'date') ?? '',
1182
+ replyTo: getHeader('reply-to') ?? undefined,
1183
+ date: getHeader('date') ?? '',
977
1184
  labelIds,
978
1185
  unread: labelIds.includes('UNREAD'),
979
1186
  starred: labelIds.includes('STARRED'),
980
1187
  isDraft: labelIds.includes('DRAFT'),
981
- messageId: this.getHeader(headers, 'message-id') ?? '',
982
- inReplyTo: this.getHeader(headers, 'in-reply-to') ?? undefined,
983
- references: this.getHeader(headers, 'references') ?? undefined,
984
- listUnsubscribe: this.getHeader(headers, 'list-unsubscribe') ?? undefined,
1188
+ messageId: getHeader('message-id') ?? '',
1189
+ inReplyTo: getHeader('in-reply-to') ?? undefined,
1190
+ references: getHeader('references') ?? undefined,
1191
+ listUnsubscribe: getHeader('list-unsubscribe') ?? undefined,
985
1192
  body,
986
1193
  mimeType,
987
- attachments: this.extractAttachmentMeta(message.payload?.parts ?? []),
1194
+ textBody,
1195
+ attachments: GmailClient.extractAttachmentMetaStatic(message.payload?.parts ?? []),
988
1196
  }
989
1197
  }
990
1198
 
991
- private parseThreadListItem(
992
- threadId: string,
993
- thread: gmail_v1.Schema$Thread,
994
- ): ThreadListItem {
995
- const messages = thread.messages ?? []
996
- // Use the last non-draft message, or the last message
1199
+ /** Parse raw gmail_v1.Schema$Thread (format: metadata) into ThreadListItem. */
1200
+ static parseRawThreadListItem(raw: gmail_v1.Schema$Thread): ThreadListItem {
1201
+ const messages = raw.messages ?? []
997
1202
  const latest =
998
1203
  messages.findLast((m) => !m.labelIds?.includes('DRAFT')) ?? messages[messages.length - 1]
999
1204
 
1000
1205
  const headers = latest?.payload?.headers ?? []
1001
1206
  const allLabels = [...new Set(messages.flatMap((m) => m.labelIds ?? []))]
1002
1207
 
1208
+ const getHeader = (name: string) =>
1209
+ headers.find((h) => h.name?.toLowerCase() === name.toLowerCase())?.value ?? null
1210
+
1003
1211
  return {
1004
- id: threadId,
1005
- historyId: thread.historyId ?? null,
1006
- snippet: latest?.snippet ?? '',
1007
- subject: (this.getHeader(headers, 'subject') ?? '(no subject)').replace(/"/g, '').trim(),
1008
- from: parseFrom(this.getHeader(headers, 'from') ?? ''),
1009
- date: this.getHeader(headers, 'date') ?? '',
1212
+ id: raw.id ?? '',
1213
+ historyId: raw.historyId ?? null,
1214
+ snippet: sanitizeSnippet(latest?.snippet ?? ''),
1215
+ subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
1216
+ from: parseFrom(getHeader('from') ?? ''),
1217
+ date: getHeader('date') ?? '',
1010
1218
  labelIds: allLabels,
1011
1219
  unread: allLabels.includes('UNREAD'),
1012
1220
  messageCount: messages.filter((m) => !m.labelIds?.includes('DRAFT')).length,
1013
1221
  }
1014
1222
  }
1015
1223
 
1224
+ /** Parse raw gmail_v1.Schema$Label[] from labels.list into our label objects. */
1225
+ static parseRawLabels(rawLabels: gmail_v1.Schema$Label[]) {
1226
+ return rawLabels.map((label) => ({
1227
+ id: label.id ?? '',
1228
+ name: label.name ?? '',
1229
+ type: (label.type ?? 'user') as 'system' | 'user',
1230
+ messageListVisibility: label.messageListVisibility ?? null,
1231
+ labelListVisibility: label.labelListVisibility ?? null,
1232
+ color: label.color
1233
+ ? {
1234
+ backgroundColor: label.color.backgroundColor ?? '',
1235
+ textColor: label.color.textColor ?? '',
1236
+ }
1237
+ : null,
1238
+ }))
1239
+ }
1240
+
1241
+ /** Parse raw gmail_v1.Schema$Label[] (with counts) into label count objects. */
1242
+ static parseRawLabelCounts(
1243
+ rawLabels: gmail_v1.Schema$Label[],
1244
+ archiveEstimate: number | null,
1245
+ ) {
1246
+ const result = rawLabels
1247
+ .map((detail) => {
1248
+ const labelName = (detail.name ?? detail.id ?? '').toLowerCase()
1249
+ const isTotalLabel = labelName === 'draft' || labelName === 'sent'
1250
+ return {
1251
+ label: labelName === 'draft' ? 'drafts' : labelName,
1252
+ count: Number(isTotalLabel ? detail.threadsTotal : detail.threadsUnread) || 0,
1253
+ }
1254
+ })
1255
+
1256
+ if (archiveEstimate !== null) {
1257
+ result.push({ label: 'archive', count: archiveEstimate })
1258
+ }
1259
+
1260
+ return result
1261
+ }
1262
+
1016
1263
  // =========================================================================
1017
- // Private: body extraction
1264
+ // Private static: body/attachment extraction (for static parse methods)
1018
1265
  // =========================================================================
1019
1266
 
1020
- private extractBody(payload: gmail_v1.Schema$MessagePart): {
1267
+ private static extractBodyStatic(payload: gmail_v1.Schema$MessagePart): {
1021
1268
  body: string
1022
1269
  mimeType: string
1270
+ textBody: string | null
1023
1271
  } {
1024
- // Direct body on payload
1025
1272
  if (payload.body?.data) {
1273
+ const mime = payload.mimeType ?? 'text/plain'
1026
1274
  return {
1027
1275
  body: decodeBase64Url(payload.body.data),
1028
- mimeType: payload.mimeType ?? 'text/plain',
1276
+ mimeType: mime,
1277
+ textBody: mime === 'text/plain' ? decodeBase64Url(payload.body.data) : null,
1029
1278
  }
1030
1279
  }
1031
1280
 
1032
1281
  if (!payload.parts) {
1033
- return { body: '', mimeType: 'text/plain' }
1282
+ return { body: '', mimeType: 'text/plain', textBody: null }
1034
1283
  }
1035
1284
 
1036
- // Prefer text/html, fallback to text/plain
1037
- const htmlBody = this.findBodyPart(payload.parts, 'text/html')
1038
- if (htmlBody) {
1039
- return { body: decodeBase64Url(htmlBody), mimeType: 'text/html' }
1285
+ const htmlData = GmailClient.findBodyPartStatic(payload.parts, 'text/html')
1286
+ const textData = GmailClient.findBodyPartStatic(payload.parts, 'text/plain')
1287
+ const textBody = textData ? decodeBase64Url(textData) : null
1288
+
1289
+ if (htmlData) {
1290
+ return { body: decodeBase64Url(htmlData), mimeType: 'text/html', textBody }
1040
1291
  }
1041
1292
 
1042
- const textBody = this.findBodyPart(payload.parts, 'text/plain')
1043
- if (textBody) {
1044
- return { body: decodeBase64Url(textBody), mimeType: 'text/plain' }
1293
+ if (textData) {
1294
+ return { body: textBody!, mimeType: 'text/plain', textBody }
1045
1295
  }
1046
1296
 
1047
- // Nested multipart (e.g. multipart/alternative inside multipart/mixed)
1048
1297
  for (const part of payload.parts) {
1049
1298
  if (part.parts) {
1050
- const nested = this.extractBody(part)
1299
+ const nested = GmailClient.extractBodyStatic(part)
1051
1300
  if (nested.body) return nested
1052
1301
  }
1053
1302
  }
1054
1303
 
1055
- return { body: '', mimeType: 'text/plain' }
1304
+ return { body: '', mimeType: 'text/plain', textBody: null }
1056
1305
  }
1057
1306
 
1058
- private findBodyPart(parts: gmail_v1.Schema$MessagePart[], mimeType: string): string | null {
1307
+ private static findBodyPartStatic(parts: gmail_v1.Schema$MessagePart[], mimeType: string): string | null {
1059
1308
  for (const part of parts) {
1060
1309
  if (part.mimeType === mimeType && part.body?.data) {
1061
1310
  return part.body.data
1062
1311
  }
1063
1312
  if (part.parts) {
1064
- const found = this.findBodyPart(part.parts, mimeType)
1313
+ const found = GmailClient.findBodyPartStatic(part.parts, mimeType)
1065
1314
  if (found) return found
1066
1315
  }
1067
1316
  }
1068
1317
  return null
1069
1318
  }
1070
1319
 
1071
- // =========================================================================
1072
- // Private: attachment handling
1073
- // =========================================================================
1074
-
1075
- private extractAttachmentMeta(parts: gmail_v1.Schema$MessagePart[]): AttachmentMeta[] {
1320
+ private static extractAttachmentMetaStatic(parts: gmail_v1.Schema$MessagePart[]): AttachmentMeta[] {
1076
1321
  const results: AttachmentMeta[] = []
1077
1322
 
1078
1323
  for (const part of parts) {
1079
1324
  if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
1080
- // Skip inline images (content-disposition: inline with content-id)
1081
1325
  const disposition =
1082
1326
  part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
1083
1327
  const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
@@ -1093,38 +1337,225 @@ export class GmailClient {
1093
1337
  }
1094
1338
  }
1095
1339
 
1096
- // Recurse into nested parts
1097
1340
  if (part.parts) {
1098
- results.push(...this.extractAttachmentMeta(part.parts))
1341
+ results.push(...GmailClient.extractAttachmentMetaStatic(part.parts))
1099
1342
  }
1100
1343
  }
1101
1344
 
1102
1345
  return results
1103
1346
  }
1104
1347
 
1105
- private findAttachmentParts(
1106
- parts: gmail_v1.Schema$MessagePart[],
1107
- ): gmail_v1.Schema$MessagePart[] {
1108
- const results: gmail_v1.Schema$MessagePart[] = []
1348
+ async getEmailAliases(): Promise<Array<{ email: string; name?: string; primary: boolean }> | AuthError | ApiError> {
1349
+ const profile = await this.getProfile()
1350
+ if (profile instanceof Error) return profile
1351
+ const primaryEmail = profile.emailAddress
1109
1352
 
1110
- for (const part of parts) {
1111
- if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
1112
- // Filter out inline CID images (same logic as Zero's findAttachments)
1113
- const disposition =
1114
- part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
1115
- const isInline = disposition.toLowerCase().includes('inline')
1116
- const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
1353
+ const aliases: Array<{ email: string; name?: string; primary: boolean }> = [
1354
+ { email: primaryEmail, primary: true },
1355
+ ]
1117
1356
 
1118
- if (!isInline || !hasContentId) {
1119
- results.push(part)
1357
+ // Boundary: sendAs.list auth errors propagate; permission-denied is expected
1358
+ // (some accounts lack Gmail settings access) and yields primary email only.
1359
+ const settings = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1360
+ withRetry(() =>
1361
+ this.gmail.users.settings.sendAs.list({ userId: 'me' }),
1362
+ ),
1363
+ )
1364
+ if (settings instanceof AuthError) return settings
1365
+ if (settings instanceof Error) return aliases // permission denied — return primary only
1366
+
1367
+ for (const alias of settings.data.sendAs ?? []) {
1368
+ if (alias.isPrimary && alias.sendAsEmail === primaryEmail) continue
1369
+ aliases.push({
1370
+ email: alias.sendAsEmail ?? '',
1371
+ name: alias.displayName ?? undefined,
1372
+ primary: alias.isPrimary ?? false,
1373
+ })
1374
+ }
1375
+
1376
+ return aliases
1377
+ }
1378
+
1379
+ // =========================================================================
1380
+ // History / sync
1381
+ // =========================================================================
1382
+
1383
+ async listHistory({
1384
+ startHistoryId,
1385
+ labelId,
1386
+ historyTypes,
1387
+ }: {
1388
+ startHistoryId: string
1389
+ labelId?: string
1390
+ historyTypes?: Array<'messageAdded' | 'messageDeleted' | 'labelAdded' | 'labelRemoved'>
1391
+ }): Promise<{ history: gmail_v1.Schema$History[]; historyId: string } | AuthError | ApiError> {
1392
+ const allHistory: gmail_v1.Schema$History[] = []
1393
+ let pageToken: string | undefined
1394
+ let latestHistoryId = startHistoryId
1395
+
1396
+ while (true) {
1397
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1398
+ withRetry(() =>
1399
+ this.gmail.users.history.list({
1400
+ userId: 'me',
1401
+ startHistoryId,
1402
+ labelId,
1403
+ historyTypes,
1404
+ pageToken,
1405
+ }),
1406
+ ),
1407
+ )
1408
+ if (res instanceof Error) return res
1409
+
1410
+ if (res.data.history) {
1411
+ allHistory.push(...res.data.history)
1412
+ }
1413
+ latestHistoryId = res.data.historyId ?? latestHistoryId
1414
+
1415
+ pageToken = res.data.nextPageToken ?? undefined
1416
+ if (!pageToken) break
1417
+ }
1418
+
1419
+ return {
1420
+ history: allHistory,
1421
+ historyId: latestHistoryId,
1422
+ }
1423
+ }
1424
+
1425
+ // =========================================================================
1426
+ // Watch: async generator for inbox polling via History API
1427
+ // =========================================================================
1428
+
1429
+ /**
1430
+ * Poll for new messages using the Gmail History API.
1431
+ * Yields WatchEvent objects as new messages arrive.
1432
+ * Handles history seeding, expiry re-seeding, and client-side query filtering.
1433
+ * Persists historyId in the DB so it survives across CLI invocations.
1434
+ */
1435
+ async *watchInbox({
1436
+ folder = 'inbox',
1437
+ intervalMs = 15_000,
1438
+ query,
1439
+ once = false,
1440
+ }: {
1441
+ folder?: string
1442
+ intervalMs?: number
1443
+ query?: string
1444
+ once?: boolean
1445
+ } = {}): AsyncGenerator<WatchEvent> {
1446
+ if (!this.account) throw new MissingDataError({ what: 'authenticated account', resource: 'watchInbox' })
1447
+
1448
+ const filterLabelId = WATCH_FOLDER_LABELS[folder]
1449
+ if (!filterLabelId) {
1450
+ throw new NotFoundError({ resource: `watch folder "${folder}". Supported: ${Object.keys(WATCH_FOLDER_LABELS).join(', ')}` })
1451
+ }
1452
+
1453
+ // Seed historyId — guaranteed non-undefined after this block
1454
+ let historyId: string = await getLastHistoryId(this.account) ?? ''
1455
+ if (!historyId) {
1456
+ const profile = await this.getProfile()
1457
+ if (profile instanceof Error) throw profile
1458
+ historyId = profile.historyId
1459
+ await setLastHistoryId(this.account, historyId)
1460
+ }
1461
+
1462
+ while (true) {
1463
+ // listHistory returns errors as values — check and handle history expiry.
1464
+ const historyResult = await this.listHistory({
1465
+ startHistoryId: historyId,
1466
+ labelId: filterLabelId,
1467
+ historyTypes: ['messageAdded'],
1468
+ })
1469
+
1470
+ if (historyResult instanceof Error) {
1471
+ if (!isHistoryExpired(historyResult)) throw historyResult
1472
+ // historyId expired — Google only keeps ~7 days. Re-seed.
1473
+ const profile = await this.getProfile()
1474
+ if (profile instanceof Error) throw profile
1475
+ historyId = profile.historyId
1476
+ await setLastHistoryId(this.account, historyId)
1477
+ // Retry once after reseed
1478
+ const retryResult = await this.listHistory({ startHistoryId: historyId, labelId: filterLabelId, historyTypes: ['messageAdded'] })
1479
+ if (retryResult instanceof Error) throw retryResult
1480
+ yield* this.pollOnceFromHistory(retryResult, historyId, query, (newId) => { historyId = newId })
1481
+ } else {
1482
+ yield* this.pollOnceFromHistory(historyResult, historyId, query, (newId) => { historyId = newId })
1483
+ }
1484
+
1485
+ if (once) return
1486
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
1487
+ }
1488
+ }
1489
+
1490
+ /** Single poll tick from pre-fetched history — yields WatchEvents for new messages. */
1491
+ private async *pollOnceFromHistory(
1492
+ historyData: { history: gmail_v1.Schema$History[]; historyId: string },
1493
+ prevHistoryId: string,
1494
+ query: string | undefined,
1495
+ updateHistoryId: (newId: string) => void,
1496
+ ): AsyncGenerator<WatchEvent> {
1497
+ const { history, historyId: newHistoryId } = historyData
1498
+
1499
+ if (newHistoryId !== prevHistoryId) {
1500
+ updateHistoryId(newHistoryId)
1501
+ await setLastHistoryId(this.account!, newHistoryId)
1502
+ }
1503
+
1504
+ if (history.length === 0) return
1505
+
1506
+ // Collect unique message IDs from messageAdded events
1507
+ const seenIds = new Set<string>()
1508
+ const messageIds: string[] = []
1509
+
1510
+ for (const entry of history) {
1511
+ for (const added of entry.messagesAdded ?? []) {
1512
+ const id = added.message?.id
1513
+ if (id && !seenIds.has(id)) {
1514
+ seenIds.add(id)
1515
+ messageIds.push(id)
1120
1516
  }
1121
1517
  }
1122
- if (part.parts) {
1123
- results.push(...this.findAttachmentParts(part.parts))
1518
+ }
1519
+
1520
+ if (messageIds.length === 0) return
1521
+
1522
+ // Hydrate messages with metadata (bounded concurrency).
1523
+ // getMessage returns errors as values — auth errors abort via mapConcurrent,
1524
+ // 404s (message deleted between history and hydration) are skipped.
1525
+ const hydrated = await mapConcurrent(messageIds, async (msgId) => {
1526
+ const msg = await this.getMessage({ messageId: msgId, format: 'metadata' })
1527
+ if (msg instanceof AuthError) return msg // abort batch
1528
+ if (msg instanceof Error) return null // skip this message
1529
+ if ('raw' in msg) return null
1530
+ if (query && !matchesQuery(msg, query)) return null
1531
+ return msg
1532
+ })
1533
+ if (hydrated instanceof Error) throw hydrated // propagate to generator consumer
1534
+
1535
+ for (const msg of hydrated) {
1536
+ if (!msg) continue
1537
+ yield {
1538
+ account: this.account!,
1539
+ type: 'new_message',
1540
+ message: msg,
1541
+ threadId: msg.threadId,
1124
1542
  }
1125
1543
  }
1544
+ }
1126
1545
 
1127
- return results
1546
+ // =========================================================================
1547
+ // Private: message parsing (delegates to static methods)
1548
+ // =========================================================================
1549
+
1550
+ private parseMessage(message: gmail_v1.Schema$Message): ParsedMessage {
1551
+ return GmailClient.parseRawMessage(message)
1552
+ }
1553
+
1554
+ private parseThreadListItem(
1555
+ threadId: string,
1556
+ thread: gmail_v1.Schema$Thread,
1557
+ ): ThreadListItem {
1558
+ return GmailClient.parseRawThreadListItem({ ...thread, id: threadId })
1128
1559
  }
1129
1560
 
1130
1561
  // =========================================================================
@@ -1211,11 +1642,13 @@ export class GmailClient {
1211
1642
  // =========================================================================
1212
1643
 
1213
1644
  /** Look up a label ID by name. Returns null if not found (never creates). */
1214
- private async lookupLabelId(labelNameOrId: string): Promise<string | null> {
1645
+ private async lookupLabelId(labelNameOrId: string): Promise<string | null | AuthError | ApiError> {
1215
1646
  if (SYSTEM_LABEL_IDS.has(labelNameOrId)) return labelNameOrId
1216
1647
  if (this.labelIdCache[labelNameOrId]) return this.labelIdCache[labelNameOrId]!
1217
1648
 
1218
- const labels = await this.listLabels()
1649
+ const labelsResult = await this.listLabels()
1650
+ if (labelsResult instanceof Error) return labelsResult
1651
+ const { parsed: labels } = labelsResult
1219
1652
  const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase())
1220
1653
  if (match) {
1221
1654
  this.labelIdCache[labelNameOrId] = match.id
@@ -1226,11 +1659,13 @@ export class GmailClient {
1226
1659
  }
1227
1660
 
1228
1661
  /** Resolve a label ID by name, auto-creating if it doesn't exist. */
1229
- private async resolveLabelId(labelNameOrId: string): Promise<string> {
1662
+ private async resolveLabelId(labelNameOrId: string): Promise<string | AuthError | ApiError> {
1230
1663
  if (SYSTEM_LABEL_IDS.has(labelNameOrId)) return labelNameOrId
1231
1664
  if (this.labelIdCache[labelNameOrId]) return this.labelIdCache[labelNameOrId]!
1232
1665
 
1233
- const labels = await this.listLabels()
1666
+ const labelsResult = await this.listLabels()
1667
+ if (labelsResult instanceof Error) return labelsResult
1668
+ const { parsed: labels } = labelsResult
1234
1669
  const match = labels.find((l) => l.name.toLowerCase() === labelNameOrId.toLowerCase())
1235
1670
  if (match) {
1236
1671
  this.labelIdCache[labelNameOrId] = match.id
@@ -1309,17 +1744,20 @@ export class GmailClient {
1309
1744
  private async getMessageIdsForThreads(
1310
1745
  threadIds: string[],
1311
1746
  filter?: (labelIds: string[]) => boolean,
1312
- ) {
1747
+ ): Promise<string[] | AuthError | ApiError> {
1313
1748
  const allIds: string[] = []
1314
1749
 
1315
- await mapConcurrent(threadIds, async (threadId) => {
1316
- const res = await withRetry(() =>
1317
- this.gmail.users.threads.get({
1318
- userId: 'me',
1319
- id: threadId,
1320
- format: 'metadata',
1321
- }),
1750
+ const result = await mapConcurrent(threadIds, async (threadId) => {
1751
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1752
+ withRetry(() =>
1753
+ this.gmail.users.threads.get({
1754
+ userId: 'me',
1755
+ id: threadId,
1756
+ format: 'metadata',
1757
+ }),
1758
+ ),
1322
1759
  )
1760
+ if (res instanceof Error) return res
1323
1761
 
1324
1762
  for (const msg of res.data.messages ?? []) {
1325
1763
  if (!msg.id) continue
@@ -1327,6 +1765,7 @@ export class GmailClient {
1327
1765
  allIds.push(msg.id)
1328
1766
  }
1329
1767
  })
1768
+ if (result instanceof Error) return result
1330
1769
 
1331
1770
  return [...new Set(allIds)]
1332
1771
  }
@@ -1387,3 +1826,169 @@ export class GmailClient {
1387
1826
  )
1388
1827
  }
1389
1828
  }
1829
+
1830
+ // ---------------------------------------------------------------------------
1831
+ // Watch: folder label mapping
1832
+ // ---------------------------------------------------------------------------
1833
+
1834
+ const WATCH_FOLDER_LABELS: Record<string, string> = {
1835
+ inbox: 'INBOX',
1836
+ sent: 'SENT',
1837
+ trash: 'TRASH',
1838
+ spam: 'SPAM',
1839
+ starred: 'STARRED',
1840
+ drafts: 'DRAFT',
1841
+ }
1842
+
1843
+ // ---------------------------------------------------------------------------
1844
+ // Watch: sync state persistence (historyId in DB)
1845
+ // ---------------------------------------------------------------------------
1846
+
1847
+ async function getLastHistoryId(account: AccountId): Promise<string | undefined> {
1848
+ const prisma = await getPrisma()
1849
+ const row = await prisma.syncState.findUnique({
1850
+ where: { email_appId_key: { email: account.email, appId: account.appId, key: 'history_id' } },
1851
+ })
1852
+ return row?.value
1853
+ }
1854
+
1855
+ async function setLastHistoryId(account: AccountId, historyId: string): Promise<void> {
1856
+ const prisma = await getPrisma()
1857
+ await prisma.syncState.upsert({
1858
+ where: { email_appId_key: { email: account.email, appId: account.appId, key: 'history_id' } },
1859
+ create: { email: account.email, appId: account.appId, key: 'history_id', value: historyId },
1860
+ update: { value: historyId },
1861
+ })
1862
+ }
1863
+
1864
+ // ---------------------------------------------------------------------------
1865
+ // Watch: history expiry detection
1866
+ // ---------------------------------------------------------------------------
1867
+
1868
+ function isHistoryExpired(err: any): boolean {
1869
+ const status = err?.code ?? err?.status ?? err?.response?.status
1870
+ if (status === 404) return true
1871
+ if (status === 400) {
1872
+ const message = err?.message ?? err?.response?.data?.error?.message ?? ''
1873
+ if (message.includes('historyId')) return true
1874
+ }
1875
+ return false
1876
+ }
1877
+
1878
+ // ---------------------------------------------------------------------------
1879
+ // Watch: client-side Gmail query matching
1880
+ // ---------------------------------------------------------------------------
1881
+ // The History API doesn't support server-side query filtering, so we parse
1882
+ // common Gmail search operators and match against message metadata.
1883
+ //
1884
+ // Supported operators: from:, to:, cc:, subject:, is:unread/read/starred,
1885
+ // has:attachment, and plain text (matches subject + from).
1886
+ // Multiple terms are AND-ed together. Quoted phrases and negation supported.
1887
+ //
1888
+ // Limitations vs full Gmail search:
1889
+ // - label: not supported (labelIds are API IDs like Label_123, not names)
1890
+ // - has:attachment uses Content-Type heuristic (metadata format lacks parts)
1891
+ // - OR, {}, newer_than:, older_than:, etc. are server-only — warned & skipped
1892
+ //
1893
+ // See https://support.google.com/mail/answer/7190 for the full Gmail spec.
1894
+ // ---------------------------------------------------------------------------
1895
+
1896
+ const SERVER_ONLY_OPERATORS = new Set([
1897
+ 'in', 'label', 'after', 'before', 'newer_than', 'older_than',
1898
+ 'filename', 'size', 'larger', 'smaller', 'deliveredto', 'rfc822msgid',
1899
+ 'list', 'category',
1900
+ ])
1901
+
1902
+ const SUPPORTED_OPERATORS = new Set([
1903
+ 'from', 'to', 'cc', 'subject', 'is', 'has',
1904
+ ])
1905
+
1906
+ interface QueryTerm {
1907
+ operator: string | null
1908
+ value: string
1909
+ negated: boolean
1910
+ }
1911
+
1912
+ const warnedOperators = new Set<string>()
1913
+
1914
+ function matchesQuery(msg: ParsedMessage, query: string): boolean {
1915
+ const terms = parseQueryTerms(query)
1916
+ return terms.every((term) => matchesTerm(msg, term))
1917
+ }
1918
+
1919
+ function parseQueryTerms(query: string): QueryTerm[] {
1920
+ const terms: QueryTerm[] = []
1921
+ const regex = /(-?)(?:(\w+):)?(?:"([^"]*)"|([\S]+))/gi
1922
+ let match: RegExpExecArray | null
1923
+
1924
+ while ((match = regex.exec(query)) !== null) {
1925
+ const negated = match[1] === '-'
1926
+ const rawOperator = match[2]?.toLowerCase() ?? null
1927
+ const value = (match[3] ?? match[4] ?? '').toLowerCase()
1928
+ if (!value) continue
1929
+
1930
+ if (!rawOperator && value === 'or') continue
1931
+
1932
+ if (rawOperator && SERVER_ONLY_OPERATORS.has(rawOperator)) {
1933
+ if (!warnedOperators.has(rawOperator)) {
1934
+ warnedOperators.add(rawOperator)
1935
+ console.error(`# --query: "${rawOperator}:" is a server-only operator (use "mail search" instead), skipping`)
1936
+ }
1937
+ continue
1938
+ }
1939
+
1940
+ if (rawOperator && !SUPPORTED_OPERATORS.has(rawOperator)) {
1941
+ if (!warnedOperators.has(rawOperator)) {
1942
+ warnedOperators.add(rawOperator)
1943
+ console.error(`# --query: unknown operator "${rawOperator}:", skipping`)
1944
+ }
1945
+ continue
1946
+ }
1947
+
1948
+ terms.push({ operator: rawOperator, value, negated })
1949
+ }
1950
+
1951
+ return terms
1952
+ }
1953
+
1954
+ function senderMatches(sender: { name?: string; email: string }, value: string): boolean {
1955
+ const full = `${sender.name ?? ''} ${sender.email}`.toLowerCase()
1956
+ return full.includes(value)
1957
+ }
1958
+
1959
+ function matchesTerm(msg: ParsedMessage, term: QueryTerm): boolean {
1960
+ let result: boolean
1961
+
1962
+ switch (term.operator) {
1963
+ case 'from':
1964
+ result = senderMatches(msg.from, term.value)
1965
+ break
1966
+ case 'to':
1967
+ result = msg.to.some((r) => senderMatches(r, term.value))
1968
+ break
1969
+ case 'cc':
1970
+ result = (msg.cc ?? []).some((r) => senderMatches(r, term.value))
1971
+ break
1972
+ case 'subject':
1973
+ result = msg.subject.toLowerCase().includes(term.value)
1974
+ break
1975
+ case 'is':
1976
+ if (term.value === 'unread') result = msg.unread
1977
+ else if (term.value === 'read') result = !msg.unread
1978
+ else if (term.value === 'starred') result = msg.starred
1979
+ else result = false
1980
+ break
1981
+ case 'has':
1982
+ if (term.value === 'attachment') result = msg.mimeType.includes('multipart/mixed')
1983
+ else result = false
1984
+ break
1985
+ default: {
1986
+ const subject = msg.subject.toLowerCase()
1987
+ const from = `${msg.from.name ?? ''} ${msg.from.email}`.toLowerCase()
1988
+ result = subject.includes(term.value) || from.includes(term.value)
1989
+ break
1990
+ }
1991
+ }
1992
+
1993
+ return term.negated ? !result : result
1994
+ }