zele 0.3.15 → 0.3.16

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 (47) hide show
  1. package/README.md +25 -0
  2. package/dist/api-utils.d.ts +3 -0
  3. package/dist/api-utils.js +6 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.js +1 -1
  6. package/dist/auth.js.map +1 -1
  7. package/dist/calendar-time.js +6 -0
  8. package/dist/calendar-time.js.map +1 -1
  9. package/dist/cli.js +6 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/commands/auth-cmd.js +1 -1
  12. package/dist/commands/auth-cmd.js.map +1 -1
  13. package/dist/commands/calendar.js +1 -1
  14. package/dist/commands/calendar.js.map +1 -1
  15. package/dist/commands/draft.js +2 -2
  16. package/dist/commands/draft.js.map +1 -1
  17. package/dist/commands/filter.d.ts +2 -0
  18. package/dist/commands/filter.js +59 -0
  19. package/dist/commands/filter.js.map +1 -0
  20. package/dist/commands/mail-actions.js +12 -2
  21. package/dist/commands/mail-actions.js.map +1 -1
  22. package/dist/commands/mail.js +176 -93
  23. package/dist/commands/mail.js.map +1 -1
  24. package/dist/gmail-client.d.ts +28 -0
  25. package/dist/gmail-client.js +129 -9
  26. package/dist/gmail-client.js.map +1 -1
  27. package/dist/mail-tui.js +3 -2
  28. package/dist/mail-tui.js.map +1 -1
  29. package/dist/output.d.ts +2 -0
  30. package/dist/output.js +4 -0
  31. package/dist/output.js.map +1 -1
  32. package/package.json +5 -4
  33. package/skills/zele/SKILL.md +112 -0
  34. package/src/api-utils.ts +7 -0
  35. package/src/auth.ts +1 -1
  36. package/src/calendar-time.test.ts +35 -0
  37. package/src/calendar-time.ts +5 -0
  38. package/src/cli.ts +6 -1
  39. package/src/commands/auth-cmd.ts +1 -1
  40. package/src/commands/calendar.ts +1 -1
  41. package/src/commands/draft.ts +2 -2
  42. package/src/commands/filter.ts +68 -0
  43. package/src/commands/mail-actions.ts +14 -2
  44. package/src/commands/mail.ts +186 -98
  45. package/src/gmail-client.ts +156 -16
  46. package/src/mail-tui.tsx +1 -2
  47. package/src/output.ts +8 -1
@@ -78,10 +78,16 @@ export interface ThreadListItem {
78
78
  snippet: string
79
79
  subject: string
80
80
  from: Sender
81
+ to: Sender[]
82
+ cc: Sender[]
81
83
  date: string
82
84
  labelIds: string[]
83
85
  unread: boolean
86
+ starred: boolean
84
87
  messageCount: number
88
+ inReplyTo: string | null
89
+ hasAttachments: boolean
90
+ listUnsubscribe: string | null
85
91
  }
86
92
 
87
93
  export interface ThreadListResult {
@@ -806,7 +812,8 @@ export class GmailClient {
806
812
  )
807
813
  if (messageIds instanceof Error) return messageIds
808
814
  if (messageIds.length === 0) return
809
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
815
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
816
+ if (mod instanceof Error) return mod
810
817
  await this.invalidateAfterThreadMutation(threadIds)
811
818
  }
812
819
 
@@ -816,7 +823,8 @@ export class GmailClient {
816
823
  )
817
824
  if (messageIds instanceof Error) return messageIds
818
825
  if (messageIds.length === 0) return
819
- await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
826
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
827
+ if (mod instanceof Error) return mod
820
828
  await this.invalidateAfterThreadMutation(threadIds)
821
829
  }
822
830
 
@@ -824,7 +832,8 @@ export class GmailClient {
824
832
  const messageIds = await this.getMessageIdsForThreads(threadIds)
825
833
  if (messageIds instanceof Error) return messageIds
826
834
  if (messageIds.length === 0) return
827
- await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
835
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
836
+ if (mod instanceof Error) return mod
828
837
  await this.invalidateAfterThreadMutation(threadIds)
829
838
  }
830
839
 
@@ -834,7 +843,8 @@ export class GmailClient {
834
843
  )
835
844
  if (messageIds instanceof Error) return messageIds
836
845
  if (messageIds.length === 0) return
837
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
846
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
847
+ if (mod instanceof Error) return mod
838
848
  await this.invalidateAfterThreadMutation(threadIds)
839
849
  }
840
850
 
@@ -861,10 +871,11 @@ export class GmailClient {
861
871
  if (messageIds instanceof Error) return messageIds
862
872
  if (messageIds.length === 0) return
863
873
 
864
- await this.batchModifyMessages(messageIds, {
874
+ const mod = await this.batchModifyMessages(messageIds, {
865
875
  addLabelIds: resolvedAdd.filter((r): r is string => typeof r === 'string'),
866
876
  removeLabelIds: resolvedRemove,
867
877
  })
878
+ if (mod instanceof Error) return mod
868
879
  await this.invalidateAfterThreadMutation(threadIds)
869
880
  }
870
881
 
@@ -892,7 +903,28 @@ export class GmailClient {
892
903
  const messageIds = await this.getMessageIdsForThreads(threadIds)
893
904
  if (messageIds instanceof Error) return messageIds
894
905
  if (messageIds.length === 0) return
895
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
906
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
907
+ if (mod instanceof Error) return mod
908
+ await this.invalidateAfterThreadMutation(threadIds)
909
+ }
910
+
911
+ async markAsSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
912
+ const messageIds = await this.getMessageIdsForThreads(threadIds)
913
+ if (messageIds instanceof Error) return messageIds
914
+ if (messageIds.length === 0) return
915
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['SPAM'], removeLabelIds: ['INBOX'] })
916
+ if (mod instanceof Error) return mod
917
+ await this.invalidateAfterThreadMutation(threadIds)
918
+ }
919
+
920
+ async unmarkSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
921
+ const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
922
+ labelIds.includes('SPAM'),
923
+ )
924
+ if (messageIds instanceof Error) return messageIds
925
+ if (messageIds.length === 0) return
926
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['SPAM'], addLabelIds: ['INBOX'] })
927
+ if (mod instanceof Error) return mod
896
928
  await this.invalidateAfterThreadMutation(threadIds)
897
929
  }
898
930
 
@@ -919,10 +951,11 @@ export class GmailClient {
919
951
  const threadIds = res.threads.map((t) => t.id)
920
952
  const messageIds = await this.getMessageIdsForThreads(threadIds)
921
953
  if (messageIds instanceof Error) return messageIds
922
- await this.batchModifyMessages(messageIds, {
954
+ const mod = await this.batchModifyMessages(messageIds, {
923
955
  addLabelIds: ['TRASH'],
924
956
  removeLabelIds: ['SPAM', 'INBOX'],
925
957
  })
958
+ if (mod instanceof Error) return mod
926
959
 
927
960
  totalDeleted += threadIds.length
928
961
  pageToken = res.nextPageToken ?? undefined
@@ -1021,6 +1054,61 @@ export class GmailClient {
1021
1054
  await this.invalidateLabels()
1022
1055
  }
1023
1056
 
1057
+ // =========================================================================
1058
+ // Filters
1059
+ // =========================================================================
1060
+
1061
+ async listFilters(): Promise<{ parsed: gmail_v1.Schema$Filter[] } | AuthError | ApiError> {
1062
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1063
+ withRetry(() => this.gmail.users.settings.filters.list({ userId: 'me' })),
1064
+ )
1065
+ if (res instanceof Error) return res
1066
+ return { parsed: res.data.filter ?? [] }
1067
+ }
1068
+
1069
+ async createFilter(opts: {
1070
+ from?: string
1071
+ query?: string
1072
+ addLabelIds?: string[]
1073
+ removeLabelIds?: string[]
1074
+ }): Promise<gmail_v1.Schema$Filter | AuthError | ApiError> {
1075
+ const criteria: gmail_v1.Schema$FilterCriteria = {}
1076
+ if (opts.from) criteria.from = opts.from
1077
+ if (opts.query) criteria.query = opts.query
1078
+
1079
+ const action: gmail_v1.Schema$FilterAction = {}
1080
+ if (opts.addLabelIds?.length) action.addLabelIds = opts.addLabelIds
1081
+ if (opts.removeLabelIds?.length) action.removeLabelIds = opts.removeLabelIds
1082
+
1083
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1084
+ withRetry(() =>
1085
+ this.gmail.users.settings.filters.create({
1086
+ userId: 'me',
1087
+ requestBody: { criteria, action },
1088
+ }),
1089
+ ),
1090
+ )
1091
+ if (res instanceof Error) return res
1092
+ return res.data
1093
+ }
1094
+
1095
+ async deleteFilter(filterId: string): Promise<void | AuthError | ApiError> {
1096
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1097
+ withRetry(() =>
1098
+ this.gmail.users.settings.filters.delete({
1099
+ userId: 'me',
1100
+ id: filterId,
1101
+ }),
1102
+ ),
1103
+ )
1104
+ if (res instanceof Error) return res
1105
+ }
1106
+
1107
+ /** Resolve a label name to its ID, auto-creating if missing. Public wrapper for filter commands. */
1108
+ async resolveLabel(nameOrId: string): Promise<string | AuthError | ApiError> {
1109
+ return this.resolveLabelId(nameOrId)
1110
+ }
1111
+
1024
1112
  // =========================================================================
1025
1113
  // Label counts (unread counts per folder/label)
1026
1114
  // =========================================================================
@@ -1260,16 +1348,47 @@ export class GmailClient {
1260
1348
  }
1261
1349
  }
1262
1350
 
1351
+ // Parse recipients from latest message
1352
+ const toHeader = getHeader('to') ?? ''
1353
+ const ccHeaders = headers
1354
+ .filter((h) => h.name?.toLowerCase() === 'cc')
1355
+ .map((h) => h.value ?? '')
1356
+ .filter((v) => v.length > 0)
1357
+
1358
+ // Check if any non-draft message in the thread is a reply (has In-Reply-To header)
1359
+ const inReplyTo =
1360
+ nonDraftMessages
1361
+ .map(
1362
+ (m) =>
1363
+ m.payload?.headers?.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value ??
1364
+ null,
1365
+ )
1366
+ .find((v) => v !== null) ?? null
1367
+
1368
+ // Check if any message has attachments (non-inline)
1369
+ const hasAttachments = messages.some((m) => this.hasNonInlineAttachments(m.payload))
1370
+
1371
+ // List-Unsubscribe from latest message
1372
+ const listUnsubscribe = getHeader('list-unsubscribe') ?? null
1373
+
1263
1374
  return {
1264
1375
  id: raw.id ?? '',
1265
1376
  historyId: raw.historyId ?? null,
1266
1377
  snippet: sanitizeSnippet(latest?.snippet ?? ''),
1267
1378
  subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
1268
1379
  from: displayFrom,
1380
+ to: toHeader ? parseAddressList(toHeader) : [],
1381
+ cc: ccHeaders.length > 0
1382
+ ? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
1383
+ : [],
1269
1384
  date: getHeader('date') ?? '',
1270
1385
  labelIds: allLabels,
1271
1386
  unread: allLabels.includes('UNREAD'),
1387
+ starred: allLabels.includes('STARRED'),
1272
1388
  messageCount: nonDraftMessages.length,
1389
+ inReplyTo,
1390
+ hasAttachments,
1391
+ listUnsubscribe,
1273
1392
  }
1274
1393
  }
1275
1394
 
@@ -1393,6 +1512,24 @@ export class GmailClient {
1393
1512
  return results
1394
1513
  }
1395
1514
 
1515
+ /** Quick check: does the message payload tree contain any non-inline attachments?
1516
+ * Checks the root part itself (some messages have attachment metadata there)
1517
+ * then recurses into child parts. */
1518
+ private hasNonInlineAttachments(part: gmail_v1.Schema$MessagePart | undefined): boolean {
1519
+ if (!part) return false
1520
+ if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
1521
+ const disposition =
1522
+ part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
1523
+ const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
1524
+ const isInline = disposition.toLowerCase().includes('inline')
1525
+ if (!isInline || !hasContentId) return true
1526
+ }
1527
+ for (const child of part.parts ?? []) {
1528
+ if (this.hasNonInlineAttachments(child)) return true
1529
+ }
1530
+ return false
1531
+ }
1532
+
1396
1533
  async getEmailAliases(): Promise<Array<{ email: string; name?: string; primary: boolean }> | AuthError | ApiError> {
1397
1534
  const profile = await this.getProfile()
1398
1535
  if (profile instanceof Error) return profile
@@ -1821,22 +1958,25 @@ export class GmailClient {
1821
1958
  private async batchModifyMessages(
1822
1959
  messageIds: string[],
1823
1960
  body: { addLabelIds?: string[]; removeLabelIds?: string[] },
1824
- ) {
1961
+ ): Promise<void | AuthError | ApiError> {
1825
1962
  if (messageIds.length === 0) return
1826
1963
 
1827
1964
  // Gmail batchModify accepts up to 1000 IDs
1828
1965
  const chunkSize = 1000
1829
1966
  for (let i = 0; i < messageIds.length; i += chunkSize) {
1830
1967
  const chunk = messageIds.slice(i, i + chunkSize)
1831
- await withRetry(() =>
1832
- this.gmail.users.messages.batchModify({
1833
- userId: 'me',
1834
- requestBody: {
1835
- ids: chunk,
1836
- ...body,
1837
- },
1838
- }),
1968
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1969
+ withRetry(() =>
1970
+ this.gmail.users.messages.batchModify({
1971
+ userId: 'me',
1972
+ requestBody: {
1973
+ ids: chunk,
1974
+ ...body,
1975
+ },
1976
+ }),
1977
+ ),
1839
1978
  )
1979
+ if (res instanceof Error) return res
1840
1980
  }
1841
1981
  }
1842
1982
 
package/src/mail-tui.tsx CHANGED
@@ -34,7 +34,6 @@ import {
34
34
  useNavigation,
35
35
  showFailureToast,
36
36
  } from 'termcast'
37
- // @ts-expect-error https://github.com/anomalyco/opentui/pull/614
38
37
  import { useTerminalDimensions } from '@opentui/react'
39
38
  import { useCachedPromise, useCachedState } from '@termcast/utils'
40
39
  import { useState, useMemo, useCallback, useEffect } from 'react'
@@ -1195,7 +1194,7 @@ export default function Command() {
1195
1194
  <Action
1196
1195
  title={thread.unread ? 'Mark as Read' : 'Mark as Unread'}
1197
1196
  icon={thread.unread ? Icon.Eye : Icon.EyeDisabled}
1198
- shortcut={{ modifiers: ['ctrl'], key: 'u' }}
1197
+ // shortcut={{ modifiers: ['ctrl'], key: 'u' }}
1199
1198
  onAction={() => withMutation(async () => {
1200
1199
  const { client } = await getClient([thread.account])
1201
1200
  const result = thread.unread
package/src/output.ts CHANGED
@@ -326,10 +326,17 @@ export function formatSender(sender: { name?: string; email: string }): string {
326
326
  // Status indicators
327
327
  // ---------------------------------------------------------------------------
328
328
 
329
- export function formatFlags(item: { unread?: boolean; starred?: boolean }): string {
329
+ export function formatFlags(item: {
330
+ unread?: boolean
331
+ starred?: boolean
332
+ hasAttachments?: boolean
333
+ inReplyTo?: string | null
334
+ }): string {
330
335
  const parts: string[] = []
331
336
  if (item.starred) parts.push('starred')
332
337
  if (item.unread) parts.push('unread')
338
+ if (item.hasAttachments) parts.push('attachment')
339
+ if (item.inReplyTo) parts.push('reply')
333
340
  return parts.join(', ')
334
341
  }
335
342