zele 0.2.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 (158) hide show
  1. package/README.md +38 -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 +28 -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 +114 -128
  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.d.ts +2 -0
  31. package/dist/commands/watch.js +73 -0
  32. package/dist/commands/watch.js.map +1 -0
  33. package/dist/db.js +12 -13
  34. package/dist/db.js.map +1 -1
  35. package/dist/generated/browser.d.ts +12 -27
  36. package/dist/generated/client.d.ts +13 -28
  37. package/dist/generated/client.js +1 -1
  38. package/dist/generated/commonInputTypes.d.ts +90 -26
  39. package/dist/generated/enums.d.ts +0 -4
  40. package/dist/generated/enums.js +0 -3
  41. package/dist/generated/enums.js.map +1 -1
  42. package/dist/generated/internal/class.d.ts +22 -55
  43. package/dist/generated/internal/class.js +12 -4
  44. package/dist/generated/internal/class.js.map +1 -1
  45. package/dist/generated/internal/prismaNamespace.d.ts +272 -511
  46. package/dist/generated/internal/prismaNamespace.js +54 -66
  47. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  48. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
  49. package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
  50. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  51. package/dist/generated/models/Account.d.ts +1637 -0
  52. package/dist/generated/models/Account.js +2 -0
  53. package/dist/generated/models/Account.js.map +1 -0
  54. package/dist/generated/models/CalendarList.d.ts +1161 -0
  55. package/dist/generated/models/CalendarList.js +2 -0
  56. package/dist/generated/models/CalendarList.js.map +1 -0
  57. package/dist/generated/models/Label.d.ts +1161 -0
  58. package/dist/generated/models/Label.js +2 -0
  59. package/dist/generated/models/Label.js.map +1 -0
  60. package/dist/generated/models/Profile.d.ts +1269 -0
  61. package/dist/generated/models/Profile.js +2 -0
  62. package/dist/generated/models/Profile.js.map +1 -0
  63. package/dist/generated/models/SyncState.d.ts +1130 -0
  64. package/dist/generated/models/SyncState.js +2 -0
  65. package/dist/generated/models/SyncState.js.map +1 -0
  66. package/dist/generated/models/Thread.d.ts +1608 -0
  67. package/dist/generated/models/Thread.js +2 -0
  68. package/dist/generated/models/Thread.js.map +1 -0
  69. package/dist/generated/models.d.ts +6 -9
  70. package/dist/gmail-client.d.ts +119 -94
  71. package/dist/gmail-client.js +862 -315
  72. package/dist/gmail-client.js.map +1 -1
  73. package/dist/mail-tui.d.ts +1 -0
  74. package/dist/mail-tui.js +517 -0
  75. package/dist/mail-tui.js.map +1 -0
  76. package/dist/output.d.ts +6 -4
  77. package/dist/output.js +124 -17
  78. package/dist/output.js.map +1 -1
  79. package/package.json +39 -11
  80. package/schema.prisma +81 -113
  81. package/src/api-utils.ts +103 -5
  82. package/src/auth.ts +224 -143
  83. package/src/calendar-client.ts +196 -89
  84. package/src/cli.ts +32 -1
  85. package/src/commands/attachment.ts +18 -19
  86. package/src/commands/auth-cmd.ts +19 -9
  87. package/src/commands/calendar.ts +42 -85
  88. package/src/commands/draft.ts +19 -22
  89. package/src/commands/label.ts +21 -57
  90. package/src/commands/mail-actions.ts +11 -19
  91. package/src/commands/mail.ts +104 -149
  92. package/src/commands/profile.ts +12 -28
  93. package/src/commands/watch.ts +88 -0
  94. package/src/db.ts +13 -16
  95. package/src/generated/browser.ts +49 -0
  96. package/src/generated/client.ts +71 -0
  97. package/src/generated/commonInputTypes.ts +332 -0
  98. package/src/generated/enums.ts +17 -0
  99. package/src/generated/internal/class.ts +250 -0
  100. package/src/generated/internal/prismaNamespace.ts +1198 -0
  101. package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
  102. package/src/generated/models/Account.ts +1848 -0
  103. package/src/generated/models/CalendarList.ts +1331 -0
  104. package/src/generated/models/Label.ts +1331 -0
  105. package/src/generated/models/Profile.ts +1439 -0
  106. package/src/generated/models/SyncState.ts +1300 -0
  107. package/src/generated/models/Thread.ts +1787 -0
  108. package/src/generated/models.ts +17 -0
  109. package/src/gmail-client.test.ts +59 -0
  110. package/src/gmail-client.ts +1034 -422
  111. package/src/mail-tui.tsx +1061 -0
  112. package/src/output.test.ts +1093 -0
  113. package/src/output.ts +128 -20
  114. package/src/schema.sql +58 -68
  115. package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
  116. package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
  117. package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
  118. package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
  119. package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
  120. package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
  121. package/AGENTS.md +0 -26
  122. package/CHANGELOG.md +0 -36
  123. package/dist/generated/models/accounts.d.ts +0 -2000
  124. package/dist/generated/models/accounts.js +0 -2
  125. package/dist/generated/models/accounts.js.map +0 -1
  126. package/dist/generated/models/calendar_events.d.ts +0 -1433
  127. package/dist/generated/models/calendar_events.js +0 -2
  128. package/dist/generated/models/calendar_events.js.map +0 -1
  129. package/dist/generated/models/calendar_lists.d.ts +0 -1131
  130. package/dist/generated/models/calendar_lists.js +0 -2
  131. package/dist/generated/models/calendar_lists.js.map +0 -1
  132. package/dist/generated/models/label_counts.d.ts +0 -1131
  133. package/dist/generated/models/label_counts.js +0 -2
  134. package/dist/generated/models/label_counts.js.map +0 -1
  135. package/dist/generated/models/labels.d.ts +0 -1131
  136. package/dist/generated/models/labels.js +0 -2
  137. package/dist/generated/models/labels.js.map +0 -1
  138. package/dist/generated/models/profiles.d.ts +0 -1131
  139. package/dist/generated/models/profiles.js +0 -2
  140. package/dist/generated/models/profiles.js.map +0 -1
  141. package/dist/generated/models/sync_states.d.ts +0 -1107
  142. package/dist/generated/models/sync_states.js +0 -2
  143. package/dist/generated/models/sync_states.js.map +0 -1
  144. package/dist/generated/models/thread_lists.d.ts +0 -1404
  145. package/dist/generated/models/thread_lists.js +0 -2
  146. package/dist/generated/models/thread_lists.js.map +0 -1
  147. package/dist/generated/models/threads.d.ts +0 -1247
  148. package/dist/generated/models/threads.js +0 -2
  149. package/dist/generated/models/threads.js.map +0 -1
  150. package/dist/gmail-cache.d.ts +0 -60
  151. package/dist/gmail-cache.js +0 -264
  152. package/dist/gmail-cache.js.map +0 -1
  153. package/docs/gogcli-gmail-implementation.md +0 -599
  154. package/scripts/test-device-code-clients.ts +0 -186
  155. package/scripts/test-micropython-scopes.ts +0 -72
  156. package/scripts/test-oauth-clients.ts +0 -257
  157. package/src/gmail-cache.ts +0 -339
  158. package/tsconfig.json +0 -16
@@ -2,6 +2,10 @@
2
2
  // Uses tsdav for CalDAV protocol and ts-ics for typed iCalendar parse/generate.
3
3
  // Auth: passes a Bearer token via headers (reuses existing google-auth-library OAuth2).
4
4
  // Google CalDAV endpoint: https://apidata.googleusercontent.com/caldav/v2/
5
+ // Cache is built into the client for stable metadata lookups (calendar list/timezone).
6
+ // Event listing is always fresh so CLI output reflects newly created/updated events.
7
+ // Raw tsdav responses are stored in the cache so the cache is resilient to changes
8
+ // in our own parsed types. Parsing happens at read time.
5
9
 
6
10
  import {
7
11
  fetchCalendars,
@@ -21,6 +25,21 @@ import {
21
25
  type IcsDateObject,
22
26
  } from 'ts-ics'
23
27
  import crypto from 'node:crypto'
28
+ import * as errore from 'errore'
29
+ import { getPrisma } from './db.js'
30
+ import { AuthError, isAuthLikeError, ApiError, NotFoundError, ParseError, MissingDataError } from './api-utils.js'
31
+
32
+ /** Boundary helper: wrap a tsdav/CalDAV call, converting auth-like errors to AuthError values.
33
+ * Non-auth errors are wrapped in ApiError so they remain error values (no throwing).
34
+ * Original error is preserved as `cause` for debugging. */
35
+ function caldavBoundary<T>(email: string, fn: () => Promise<T>) {
36
+ return errore.tryAsync({
37
+ try: fn,
38
+ catch: (err) => isAuthLikeError(err)
39
+ ? new AuthError({ email, reason: String(err) })
40
+ : new ApiError({ reason: String(err), cause: err }),
41
+ })
42
+ }
24
43
 
25
44
  // ---------------------------------------------------------------------------
26
45
  // Types (kept identical to previous API so commands layer is unchanged)
@@ -212,15 +231,13 @@ function icsEventToCalendarEvent(event: IcsEvent, calObj?: DAVCalendarObject): C
212
231
  }
213
232
  }
214
233
 
215
- /** Parse all events from a raw iCal data string using ts-ics */
216
- function parseICalData(data: string, calObj?: DAVCalendarObject): CalendarEvent[] {
217
- try {
218
- const calendar = convertIcsCalendar(undefined, data)
219
- return (calendar.events ?? []).map((ev) => icsEventToCalendarEvent(ev, calObj))
220
- } catch (err) {
221
- console.error('[calendar] failed to parse iCal data:', err instanceof Error ? err.stack ?? err.message : err)
222
- return []
223
- }
234
+ /** Parse all events from a raw iCal data string using ts-ics.
235
+ * Boundary: convertIcsCalendar (ts-ics library) may throw on malformed iCal.
236
+ * Returns ParseError on failure so callers can decide how to handle it. */
237
+ function parseICalData(data: string, calObj?: DAVCalendarObject): ParseError | CalendarEvent[] {
238
+ const calendar = errore.tryFn(() => convertIcsCalendar(undefined, data))
239
+ if (calendar instanceof Error) return new ParseError({ what: 'iCal data', reason: calendar.message })
240
+ return (calendar.events ?? []).map((ev) => icsEventToCalendarEvent(ev, calObj))
224
241
  }
225
242
 
226
243
  /** Build an iCal string from event properties using ts-ics */
@@ -307,15 +324,25 @@ function extractTimezone(tz?: string): string {
307
324
 
308
325
  const GOOGLE_CALDAV_URL = 'https://apidata.googleusercontent.com/caldav/v2/'
309
326
 
327
+ const TTL = {
328
+ CALENDAR_LIST: 30 * 60 * 1000, // 30 minutes
329
+ } as const
330
+
331
+ function isExpired(createdAt: Date, ttlMs: number): boolean {
332
+ return createdAt.getTime() + ttlMs < Date.now()
333
+ }
334
+
310
335
  export class CalendarClient {
311
336
  private headers: Record<string, string>
312
337
  private email: string
338
+ private appId: string
313
339
  private calendarCache: DAVCalendar[] | null = null
314
340
  private timezoneCache: Record<string, string> = {}
315
341
 
316
- constructor({ accessToken, email }: { accessToken: string; email: string }) {
342
+ constructor({ accessToken, email, appId }: { accessToken: string; email: string; appId: string }) {
317
343
  this.headers = { Authorization: `Bearer ${accessToken}` }
318
344
  this.email = email
345
+ this.appId = appId
319
346
  }
320
347
 
321
348
  /** Update the access token (e.g. after refresh) */
@@ -323,30 +350,63 @@ export class CalendarClient {
323
350
  this.headers = { Authorization: `Bearer ${token}` }
324
351
  }
325
352
 
353
+ // =========================================================================
354
+ // Cache helpers (private)
355
+ // =========================================================================
356
+
357
+ private get account() {
358
+ return { email: this.email, appId: this.appId }
359
+ }
360
+
361
+ private async getCachedCalendarList(): Promise<CalendarListItem[] | undefined> {
362
+ const prisma = await getPrisma()
363
+ const row = await prisma.calendarList.findUnique({ where: { email_appId: this.account } })
364
+ if (!row || isExpired(row.createdAt, row.ttlMs)) return undefined
365
+ return JSON.parse(row.rawData) as CalendarListItem[]
366
+ }
367
+
368
+ private async cacheCalendarListData(data: CalendarListItem[]): Promise<void> {
369
+ const prisma = await getPrisma()
370
+ await prisma.calendarList.upsert({
371
+ where: { email_appId: this.account },
372
+ create: { ...this.account, rawData: JSON.stringify(data), ttlMs: TTL.CALENDAR_LIST, createdAt: new Date() },
373
+ update: { rawData: JSON.stringify(data), ttlMs: TTL.CALENDAR_LIST, createdAt: new Date() },
374
+ })
375
+ }
376
+
377
+ private async invalidateCalendarLists(): Promise<void> {
378
+ const prisma = await getPrisma()
379
+ await prisma.calendarList.deleteMany({ where: this.account })
380
+ }
381
+
326
382
  // =========================================================================
327
383
  // Internal: fetch DAVCalendar list (cached per instance)
328
384
  // =========================================================================
329
385
 
330
- private async fetchDAVCalendars(): Promise<DAVCalendar[]> {
386
+ private async fetchDAVCalendars(): Promise<DAVCalendar[] | AuthError | ApiError> {
331
387
  if (this.calendarCache) return this.calendarCache
332
388
 
333
- const calendars = await fetchCalendars({
334
- account: {
335
- serverUrl: GOOGLE_CALDAV_URL,
336
- rootUrl: GOOGLE_CALDAV_URL,
337
- accountType: 'caldav',
338
- homeUrl: `${GOOGLE_CALDAV_URL}${this.email}/`,
339
- },
340
- headers: this.headers,
341
- })
389
+ const calendars = await caldavBoundary(this.email, () =>
390
+ fetchCalendars({
391
+ account: {
392
+ serverUrl: GOOGLE_CALDAV_URL,
393
+ rootUrl: GOOGLE_CALDAV_URL,
394
+ accountType: 'caldav',
395
+ homeUrl: `${GOOGLE_CALDAV_URL}${this.email}/`,
396
+ },
397
+ headers: this.headers,
398
+ }),
399
+ )
400
+ if (calendars instanceof Error) return calendars
342
401
 
343
402
  this.calendarCache = calendars
344
403
  return calendars
345
404
  }
346
405
 
347
406
  /** Resolve a calendarId to a DAVCalendar. 'primary' maps to the user's email. */
348
- private async resolveCalendar(calendarId: string): Promise<DAVCalendar> {
407
+ private async resolveCalendar(calendarId: string): Promise<DAVCalendar | AuthError | ApiError | NotFoundError> {
349
408
  const calendars = await this.fetchDAVCalendars()
409
+ if (calendars instanceof Error) return calendars
350
410
 
351
411
  // 'primary' = the user's own calendar (URL contains their email)
352
412
  const targetId = calendarId === 'primary' ? this.email : calendarId
@@ -358,7 +418,7 @@ export class CalendarClient {
358
418
  })
359
419
 
360
420
  if (!match) {
361
- throw new Error(`Calendar not found: ${calendarId}. Available: ${calendars.map((c) => c.displayName || c.url).join(', ')}`)
421
+ return new NotFoundError({ resource: `calendar "${calendarId}". Available: ${calendars.map((c) => c.displayName || c.url).join(', ')}` })
362
422
  }
363
423
 
364
424
  return match
@@ -368,10 +428,15 @@ export class CalendarClient {
368
428
  // Calendar list
369
429
  // =========================================================================
370
430
 
371
- async listCalendars(): Promise<CalendarListItem[]> {
431
+ async listCalendars(): Promise<CalendarListItem[] | AuthError | ApiError> {
432
+ // Check cache
433
+ const cached = await this.getCachedCalendarList()
434
+ if (cached) return cached
435
+
372
436
  const calendars = await this.fetchDAVCalendars()
437
+ if (calendars instanceof Error) return calendars
373
438
 
374
- return calendars.map((cal) => {
439
+ const result = calendars.map((cal) => {
375
440
  // Extract calendar ID from URL
376
441
  // URL looks like: https://apidata.googleusercontent.com/caldav/v2/user%40gmail.com/events/
377
442
  const urlParts = cal.url.replace(/\/$/, '').split('/')
@@ -392,29 +457,36 @@ export class CalendarClient {
392
457
  backgroundColor: cal.calendarColor ?? '',
393
458
  }
394
459
  })
460
+
461
+ // Write cache
462
+ await this.cacheCalendarListData(result)
463
+
464
+ return result
395
465
  }
396
466
 
397
467
  // =========================================================================
398
468
  // Timezone
399
469
  // =========================================================================
400
470
 
401
- async getTimezone(calendarId = 'primary'): Promise<string> {
471
+ async getTimezone(calendarId = 'primary'): Promise<string | AuthError | ApiError | NotFoundError> {
402
472
  if (this.timezoneCache[calendarId]) return this.timezoneCache[calendarId]!
403
473
 
404
- try {
405
- const cal = await this.resolveCalendar(calendarId)
406
- const tz = extractTimezone(cal.timezone)
407
- this.timezoneCache[calendarId] = tz
408
- return tz
409
- } catch (err) {
410
- console.error('[calendar] failed to resolve timezone for', calendarId, '-', err instanceof Error ? err.stack ?? err.message : err)
474
+ const cal = await this.resolveCalendar(calendarId)
475
+ if (cal instanceof Error) return cal
476
+
477
+ // extractTimezone is a pure function (regex on string) — no try/catch needed.
478
+ // Fallback to calendar list metadata if the DAVCalendar has no timezone data.
479
+ let tz = extractTimezone(cal.timezone)
480
+ if (tz === 'UTC' && cal.timezone) {
481
+ // extractTimezone returned UTC as default — check calendar list for a better match
411
482
  const calendars = await this.listCalendars()
483
+ if (calendars instanceof Error) return calendars
412
484
  const target = calendarId === 'primary' ? this.email : calendarId
413
485
  const match = calendars.find((c) => c.id.toLowerCase() === target.toLowerCase())
414
- const tz = match?.timezone || 'UTC'
415
- this.timezoneCache[calendarId] = tz
416
- return tz
486
+ if (match?.timezone && match.timezone !== 'UTC') tz = match.timezone
417
487
  }
488
+ this.timezoneCache[calendarId] = tz
489
+ return tz
418
490
  }
419
491
 
420
492
  // =========================================================================
@@ -435,9 +507,13 @@ export class CalendarClient {
435
507
  query?: string
436
508
  maxResults?: number
437
509
  pageToken?: string
438
- } = {}): Promise<EventListResult> {
510
+ } = {}): Promise<EventListResult | AuthError | ApiError | NotFoundError> {
511
+ // Always fresh: event lists are user-facing live data.
512
+
439
513
  const cal = await this.resolveCalendar(calendarId)
514
+ if (cal instanceof Error) return cal
440
515
  const tz = await this.getTimezone(calendarId)
516
+ if (tz instanceof Error) return tz
441
517
 
442
518
  const fetchOpts: Parameters<typeof fetchCalendarObjects>[0] = {
443
519
  calendar: cal,
@@ -449,12 +525,15 @@ export class CalendarClient {
449
525
  fetchOpts.timeRange = { start: timeMin, end: timeMax }
450
526
  }
451
527
 
452
- const calObjects = await fetchCalendarObjects(fetchOpts)
528
+ const calObjects = await caldavBoundary(this.email, () => fetchCalendarObjects(fetchOpts))
529
+ if (calObjects instanceof Error) return calObjects
453
530
 
454
531
  let events: CalendarEvent[] = []
455
532
  for (const obj of calObjects) {
456
533
  if (!obj.data) continue
457
- events.push(...parseICalData(obj.data, obj))
534
+ const parsed = parseICalData(obj.data, obj)
535
+ if (parsed instanceof ParseError) continue // skip unparseable calendar objects
536
+ events.push(...parsed)
458
537
  }
459
538
 
460
539
  if (query) {
@@ -468,11 +547,13 @@ export class CalendarClient {
468
547
 
469
548
  events.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
470
549
 
471
- return {
550
+ const result: EventListResult = {
472
551
  events: events.slice(0, maxResults),
473
552
  nextPageToken: null,
474
553
  timezone: tz,
475
554
  }
555
+
556
+ return result
476
557
  }
477
558
 
478
559
  async getEvent({
@@ -481,23 +562,28 @@ export class CalendarClient {
481
562
  }: {
482
563
  calendarId?: string
483
564
  eventId: string
484
- }): Promise<CalendarEvent> {
565
+ }): Promise<CalendarEvent | AuthError | ApiError | NotFoundError> {
485
566
  const cal = await this.resolveCalendar(calendarId)
567
+ if (cal instanceof Error) return cal
486
568
 
487
- const calObjects = await fetchCalendarObjects({
488
- calendar: cal,
489
- headers: this.headers,
490
- urlFilter: () => true,
491
- })
569
+ const calObjects = await caldavBoundary(this.email, () =>
570
+ fetchCalendarObjects({
571
+ calendar: cal,
572
+ headers: this.headers,
573
+ urlFilter: () => true,
574
+ }),
575
+ )
576
+ if (calObjects instanceof Error) return calObjects
492
577
 
493
578
  for (const obj of calObjects) {
494
579
  if (!obj.data) continue
495
580
  const events = parseICalData(obj.data, obj)
581
+ if (events instanceof ParseError) continue // skip unparseable calendar objects
496
582
  const match = events.find((e) => e.id === eventId || e.uid === eventId)
497
583
  if (match) return match
498
584
  }
499
585
 
500
- throw new Error(`Event not found: ${eventId}`)
586
+ return new NotFoundError({ resource: `event "${eventId}"` })
501
587
  }
502
588
 
503
589
  async createEvent({
@@ -530,8 +616,9 @@ export class CalendarClient {
530
616
  colorId?: string
531
617
  visibility?: string
532
618
  transparency?: string
533
- }): Promise<CalendarEvent> {
619
+ }): Promise<CalendarEvent | AuthError | ApiError | NotFoundError | ParseError> {
534
620
  const cal = await this.resolveCalendar(calendarId)
621
+ if (cal instanceof Error) return cal
535
622
  const uid = generateUID()
536
623
 
537
624
  const iCalString = buildICalString({
@@ -550,16 +637,20 @@ export class CalendarClient {
550
637
 
551
638
  const filename = `${uid.split('@')[0]}.ics`
552
639
 
553
- await createCalendarObject({
554
- calendar: cal,
555
- filename,
556
- iCalString,
557
- headers: this.headers,
558
- })
640
+ const createResult = await caldavBoundary(this.email, () =>
641
+ createCalendarObject({
642
+ calendar: cal,
643
+ filename,
644
+ iCalString,
645
+ headers: this.headers,
646
+ }),
647
+ )
648
+ if (createResult instanceof Error) return createResult
559
649
 
560
650
  const events = parseICalData(iCalString)
651
+ if (events instanceof ParseError) return events
561
652
  const event = events[0]
562
- if (!event) throw new Error('Failed to parse created event')
653
+ if (!event) return new ParseError({ what: 'created event', reason: 'iCal round-trip produced no events' })
563
654
  event.url = `${cal.url}${filename}`
564
655
 
565
656
  return event
@@ -595,10 +686,11 @@ export class CalendarClient {
595
686
  colorId?: string
596
687
  visibility?: string
597
688
  transparency?: string
598
- }): Promise<CalendarEvent> {
689
+ }): Promise<CalendarEvent | AuthError | ApiError | NotFoundError | MissingDataError | ParseError> {
599
690
  const existing = await this.getEvent({ calendarId, eventId })
691
+ if (existing instanceof Error) return existing
600
692
  if (!existing.url || !existing.etag) {
601
- throw new Error(`Cannot update event: missing CalDAV URL or etag for event ${eventId}`)
693
+ return new MissingDataError({ what: 'CalDAV URL or etag', resource: `event ${eventId}` })
602
694
  }
603
695
 
604
696
  // Handle attendee add/remove
@@ -635,14 +727,18 @@ export class CalendarClient {
635
727
  organizer: { email: this.email },
636
728
  })
637
729
 
638
- await updateCalendarObject({
639
- calendarObject: { url: existing.url, data: iCalString, etag: existing.etag },
640
- headers: this.headers,
641
- })
730
+ const updateResult = await caldavBoundary(this.email, () =>
731
+ updateCalendarObject({
732
+ calendarObject: { url: existing.url!, data: iCalString, etag: existing.etag },
733
+ headers: this.headers,
734
+ }),
735
+ )
736
+ if (updateResult instanceof Error) return updateResult
642
737
 
643
738
  const events = parseICalData(iCalString)
739
+ if (events instanceof ParseError) return events
644
740
  const event = events[0]
645
- if (!event) throw new Error('Failed to parse updated event')
741
+ if (!event) return new ParseError({ what: 'updated event', reason: 'iCal round-trip produced no events' })
646
742
  event.url = existing.url
647
743
  event.etag = existing.etag
648
744
 
@@ -655,16 +751,21 @@ export class CalendarClient {
655
751
  }: {
656
752
  calendarId?: string
657
753
  eventId: string
658
- }): Promise<void> {
754
+ }): Promise<void | AuthError | ApiError | NotFoundError | MissingDataError> {
659
755
  const existing = await this.getEvent({ calendarId, eventId })
756
+ if (existing instanceof Error) return existing
660
757
  if (!existing.url) {
661
- throw new Error(`Cannot delete event: missing CalDAV URL for event ${eventId}`)
758
+ return new MissingDataError({ what: 'CalDAV URL', resource: `event ${eventId}` })
662
759
  }
663
760
 
664
- await deleteCalendarObject({
665
- calendarObject: { url: existing.url, etag: existing.etag },
666
- headers: this.headers,
667
- })
761
+ const deleteResult = await caldavBoundary(this.email, () =>
762
+ deleteCalendarObject({
763
+ calendarObject: { url: existing.url!, etag: existing.etag },
764
+ headers: this.headers,
765
+ }),
766
+ )
767
+ if (deleteResult instanceof Error) return deleteResult
768
+
668
769
  }
669
770
 
670
771
  async respondToEvent({
@@ -677,10 +778,11 @@ export class CalendarClient {
677
778
  eventId: string
678
779
  status: 'accepted' | 'declined' | 'tentative'
679
780
  comment?: string
680
- }): Promise<CalendarEvent> {
781
+ }): Promise<CalendarEvent | AuthError | ApiError | NotFoundError | MissingDataError | ParseError> {
681
782
  const existing = await this.getEvent({ calendarId, eventId })
783
+ if (existing instanceof Error) return existing
682
784
  if (!existing.url || !existing.etag) {
683
- throw new Error(`Cannot respond to event: missing CalDAV URL or etag for event ${eventId}`)
785
+ return new MissingDataError({ what: 'CalDAV URL or etag', resource: `event ${eventId}` })
684
786
  }
685
787
 
686
788
  // Update our PARTSTAT in attendees
@@ -708,14 +810,18 @@ export class CalendarClient {
708
810
  : { email: this.email },
709
811
  })
710
812
 
711
- await updateCalendarObject({
712
- calendarObject: { url: existing.url, data: iCalString, etag: existing.etag },
713
- headers: this.headers,
714
- })
813
+ const respondResult = await caldavBoundary(this.email, () =>
814
+ updateCalendarObject({
815
+ calendarObject: { url: existing.url!, data: iCalString, etag: existing.etag },
816
+ headers: this.headers,
817
+ }),
818
+ )
819
+ if (respondResult instanceof Error) return respondResult
715
820
 
716
821
  const events = parseICalData(iCalString)
822
+ if (events instanceof ParseError) return events
717
823
  const event = events[0]
718
- if (!event) throw new Error('Failed to parse response event')
824
+ if (!event) return new ParseError({ what: 'response event', reason: 'iCal round-trip produced no events' })
719
825
  event.url = existing.url
720
826
 
721
827
  return event
@@ -733,23 +839,24 @@ export class CalendarClient {
733
839
  const results: FreeBusyResult[] = []
734
840
 
735
841
  for (const calId of calendarIds) {
736
- try {
737
- const { events } = await this.listEvents({
738
- calendarId: calId,
739
- timeMin,
740
- timeMax,
741
- maxResults: 200,
742
- })
743
-
744
- const busy: FreeBusyBlock[] = events
745
- .filter((e) => e.transparency !== 'transparent' && !e.allDay)
746
- .map((e) => ({ start: e.start, end: e.end }))
747
-
748
- results.push({ calendar: calId, busy })
749
- } catch (err) {
750
- console.error('[calendar] freebusy failed for', calId, '-', err instanceof Error ? err.stack ?? err.message : err)
842
+ // listEvents returns errors as values — no try/catch needed.
843
+ // Errors (auth, not found) yield an empty busy list for that calendar.
844
+ const eventsResult = await this.listEvents({
845
+ calendarId: calId,
846
+ timeMin,
847
+ timeMax,
848
+ maxResults: 200,
849
+ })
850
+ if (eventsResult instanceof Error) {
751
851
  results.push({ calendar: calId, busy: [] })
852
+ continue
752
853
  }
854
+
855
+ const busy: FreeBusyBlock[] = eventsResult.events
856
+ .filter((e: CalendarEvent) => e.transparency !== 'transparent' && !e.allDay)
857
+ .map((e: CalendarEvent) => ({ start: e.start, end: e.end }))
858
+
859
+ results.push({ calendar: calId, busy })
753
860
  }
754
861
 
755
862
  return results
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { goke } from 'goke'
8
8
  import { z } from 'zod'
9
+ import React from 'react'
9
10
  import { registerAuthCommands } from './commands/auth-cmd.js'
10
11
  import { registerMailCommands } from './commands/mail.js'
11
12
  import { registerMailActionCommands } from './commands/mail-actions.js'
@@ -14,6 +15,7 @@ import { registerLabelCommands } from './commands/label.js'
14
15
  import { registerAttachmentCommands } from './commands/attachment.js'
15
16
  import { registerProfileCommands } from './commands/profile.js'
16
17
  import { registerCalendarCommands } from './commands/calendar.js'
18
+ import { registerWatchCommands } from './commands/watch.js'
17
19
 
18
20
  const cli = goke('zele')
19
21
 
@@ -26,6 +28,34 @@ cli.option(
26
28
  z.array(z.string()).describe('Filter by email account (repeatable)'),
27
29
  )
28
30
 
31
+ // ---------------------------------------------------------------------------
32
+ // Default command (TUI)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ cli
36
+ .command('', 'Browse emails in TUI')
37
+ .action(async () => {
38
+ if (typeof (globalThis as { Bun?: unknown }).Bun === 'undefined') {
39
+ const pc = await import('picocolors')
40
+ const isWindows = process.platform === 'win32'
41
+ const installCmd = isWindows
42
+ ? 'powershell -c "irm bun.sh/install.ps1 | iex"'
43
+ : 'curl -fsSL https://bun.sh/install | bash'
44
+ console.error(
45
+ pc.default.red('Error: ') +
46
+ 'The TUI requires Bun to run.\n\n' +
47
+ 'Install Bun:\n' +
48
+ ` ${pc.default.cyan(installCmd)}\n\n` +
49
+ 'Then run:\n' +
50
+ ` ${pc.default.cyan('zele')}`,
51
+ )
52
+ process.exit(1)
53
+ }
54
+ const { renderWithProviders } = await import('termcast')
55
+ const { default: Command } = await import('./mail-tui.js')
56
+ await renderWithProviders(React.createElement(Command))
57
+ })
58
+
29
59
  // ---------------------------------------------------------------------------
30
60
  // Register all command modules (auth first so login/logout/whoami appear at top of --help)
31
61
  // ---------------------------------------------------------------------------
@@ -38,13 +68,14 @@ registerDraftCommands(cli)
38
68
  registerLabelCommands(cli)
39
69
  registerAttachmentCommands(cli)
40
70
  registerCalendarCommands(cli)
71
+ registerWatchCommands(cli)
41
72
 
42
73
  // ---------------------------------------------------------------------------
43
74
  // Help & version
44
75
  // ---------------------------------------------------------------------------
45
76
 
46
77
  cli.help()
47
- cli.version('0.2.0')
78
+ cli.version('0.3.5')
48
79
 
49
80
  // ---------------------------------------------------------------------------
50
81
  // Parse & run
@@ -1,5 +1,5 @@
1
1
  // Attachment commands: list, get (download).
2
- // Lists attachments for a message and downloads them to disk.
2
+ // Lists attachments for a thread and downloads them to disk.
3
3
  // Skips re-download if file already exists with same size (like gogcli).
4
4
 
5
5
  import type { Goke } from 'goke'
@@ -7,8 +7,8 @@ import { z } from 'zod'
7
7
  import fs from 'node:fs'
8
8
  import path from 'node:path'
9
9
  import { getClient } from '../auth.js'
10
- import { GmailClient } from '../gmail-client.js'
11
10
  import * as out from '../output.js'
11
+ import { handleCommandError } from '../output.js'
12
12
 
13
13
  export function registerAttachmentCommands(cli: Goke) {
14
14
  // =========================================================================
@@ -16,33 +16,31 @@ export function registerAttachmentCommands(cli: Goke) {
16
16
  // =========================================================================
17
17
 
18
18
  cli
19
- .command('attachment list <messageId>', 'List attachments for a message')
20
- .action(async (messageId, options) => {
19
+ .command('attachment list <threadId>', 'List attachments for all messages in a thread')
20
+ .action(async (threadId, options) => {
21
21
  const { client } = await getClient(options.account)
22
22
 
23
- const msg = await client.getMessage({ messageId })
24
- if ('raw' in msg) {
25
- out.error('Cannot list attachments for raw messages')
26
- process.exit(1)
27
- }
28
-
29
- const attachments = msg.attachments
23
+ const { parsed: thread } = await client.getThread({ threadId })
24
+ const attachments = thread.messages.flatMap((msg) =>
25
+ msg.attachments.map((attachment) => ({
26
+ thread_id: thread.id,
27
+ message_id: msg.id,
28
+ attachment_id: attachment.attachmentId,
29
+ filename: attachment.filename,
30
+ type: attachment.mimeType,
31
+ size: formatSize(attachment.size),
32
+ })),
33
+ )
30
34
 
31
35
  if (attachments.length === 0) {
32
36
  out.hint('No attachments')
33
37
  return
34
38
  }
35
39
 
36
- out.printList(
37
- attachments.map((a) => ({
38
- attachment_id: a.attachmentId,
39
- filename: a.filename,
40
- type: a.mimeType,
41
- size: formatSize(a.size),
42
- })),
43
- )
40
+ out.printList(attachments)
44
41
 
45
42
  out.hint(`${attachments.length} attachment(s)`)
43
+ out.hint('Use: zele attachment get <messageId> <attachmentId>')
46
44
  })
47
45
 
48
46
  // =========================================================================
@@ -58,6 +56,7 @@ export function registerAttachmentCommands(cli: Goke) {
58
56
 
59
57
  // Get attachment metadata first
60
58
  const msg = await client.getMessage({ messageId })
59
+ if (msg instanceof Error) return handleCommandError(msg)
61
60
  if ('raw' in msg) {
62
61
  out.error('Cannot get attachments for raw messages')
63
62
  process.exit(1)