zele 0.3.15 → 0.3.17

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 (80) hide show
  1. package/README.md +113 -33
  2. package/dist/api-utils.d.ts +7 -0
  3. package/dist/api-utils.js +12 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.d.ts +71 -9
  6. package/dist/auth.js +187 -11
  7. package/dist/auth.js.map +1 -1
  8. package/dist/calendar-time.js +6 -0
  9. package/dist/calendar-time.js.map +1 -1
  10. package/dist/cli.js +6 -1
  11. package/dist/cli.js.map +1 -1
  12. package/dist/commands/attachment.js +2 -0
  13. package/dist/commands/attachment.js.map +1 -1
  14. package/dist/commands/auth-cmd.js +104 -6
  15. package/dist/commands/auth-cmd.js.map +1 -1
  16. package/dist/commands/calendar.js +1 -1
  17. package/dist/commands/calendar.js.map +1 -1
  18. package/dist/commands/draft.js +9 -3
  19. package/dist/commands/draft.js.map +1 -1
  20. package/dist/commands/filter.d.ts +2 -0
  21. package/dist/commands/filter.js +64 -0
  22. package/dist/commands/filter.js.map +1 -0
  23. package/dist/commands/label.js +19 -9
  24. package/dist/commands/label.js.map +1 -1
  25. package/dist/commands/mail-actions.js +12 -2
  26. package/dist/commands/mail-actions.js.map +1 -1
  27. package/dist/commands/mail.js +194 -84
  28. package/dist/commands/mail.js.map +1 -1
  29. package/dist/commands/profile.js +25 -18
  30. package/dist/commands/profile.js.map +1 -1
  31. package/dist/db.js +24 -0
  32. package/dist/db.js.map +1 -1
  33. package/dist/generated/internal/class.js +2 -2
  34. package/dist/generated/internal/class.js.map +1 -1
  35. package/dist/generated/internal/prismaNamespace.d.ts +2 -0
  36. package/dist/generated/internal/prismaNamespace.js +2 -0
  37. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  38. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
  39. package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
  40. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  41. package/dist/generated/models/Account.d.ts +97 -1
  42. package/dist/gmail-client.d.ts +42 -0
  43. package/dist/gmail-client.js +175 -9
  44. package/dist/gmail-client.js.map +1 -1
  45. package/dist/imap-smtp-client.d.ts +235 -0
  46. package/dist/imap-smtp-client.js +1225 -0
  47. package/dist/imap-smtp-client.js.map +1 -0
  48. package/dist/mail-tui.js +3 -2
  49. package/dist/mail-tui.js.map +1 -1
  50. package/dist/output.d.ts +2 -0
  51. package/dist/output.js +4 -0
  52. package/dist/output.js.map +1 -1
  53. package/package.json +9 -5
  54. package/schema.prisma +7 -5
  55. package/skills/zele/SKILL.md +141 -0
  56. package/src/api-utils.ts +13 -0
  57. package/src/auth.ts +283 -15
  58. package/src/calendar-time.test.ts +35 -0
  59. package/src/calendar-time.ts +5 -0
  60. package/src/cli.ts +6 -1
  61. package/src/commands/attachment.ts +1 -0
  62. package/src/commands/auth-cmd.ts +112 -6
  63. package/src/commands/calendar.ts +1 -1
  64. package/src/commands/draft.ts +7 -3
  65. package/src/commands/filter.ts +74 -0
  66. package/src/commands/label.ts +22 -11
  67. package/src/commands/mail-actions.ts +16 -3
  68. package/src/commands/mail.ts +207 -89
  69. package/src/commands/profile.ts +27 -17
  70. package/src/db.ts +28 -0
  71. package/src/generated/internal/class.ts +2 -2
  72. package/src/generated/internal/prismaNamespace.ts +2 -0
  73. package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
  74. package/src/generated/models/Account.ts +97 -1
  75. package/src/gmail-client.test.ts +155 -2
  76. package/src/gmail-client.ts +221 -16
  77. package/src/imap-smtp-client.ts +1381 -0
  78. package/src/mail-tui.tsx +3 -3
  79. package/src/output.ts +8 -1
  80. package/src/schema.sql +2 -0
@@ -22,6 +22,18 @@ import type { AccountId } from './auth.js'
22
22
  // Types
23
23
  // ---------------------------------------------------------------------------
24
24
 
25
+ export type AuthVerdict = 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror' | 'bestguesspass'
26
+
27
+ export interface AuthResult {
28
+ spf: AuthVerdict
29
+ dkim: AuthVerdict
30
+ dmarc: AuthVerdict
31
+ /** true when all three are 'pass' — the email is fully authenticated */
32
+ authentic: boolean
33
+ /** raw Authentication-Results header value */
34
+ raw: string
35
+ }
36
+
25
37
  export interface Sender {
26
38
  name?: string
27
39
  email: string
@@ -50,6 +62,8 @@ export interface ParsedMessage {
50
62
  mimeType: string // 'text/plain' or 'text/html'
51
63
  textBody: string | null // decoded text/plain body when available (for reply parsing)
52
64
  attachments: AttachmentMeta[]
65
+ /** SPF/DKIM/DMARC authentication results from Gmail. null for sent/draft messages. */
66
+ auth: AuthResult | null
53
67
  }
54
68
 
55
69
  export interface AttachmentMeta {
@@ -78,10 +92,16 @@ export interface ThreadListItem {
78
92
  snippet: string
79
93
  subject: string
80
94
  from: Sender
95
+ to: Sender[]
96
+ cc: Sender[]
81
97
  date: string
82
98
  labelIds: string[]
83
99
  unread: boolean
100
+ starred: boolean
84
101
  messageCount: number
102
+ inReplyTo: string | null
103
+ hasAttachments: boolean
104
+ listUnsubscribe: string | null
85
105
  }
86
106
 
87
107
  export interface ThreadListResult {
@@ -806,7 +826,8 @@ export class GmailClient {
806
826
  )
807
827
  if (messageIds instanceof Error) return messageIds
808
828
  if (messageIds.length === 0) return
809
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
829
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['UNREAD'] })
830
+ if (mod instanceof Error) return mod
810
831
  await this.invalidateAfterThreadMutation(threadIds)
811
832
  }
812
833
 
@@ -816,7 +837,8 @@ export class GmailClient {
816
837
  )
817
838
  if (messageIds instanceof Error) return messageIds
818
839
  if (messageIds.length === 0) return
819
- await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
840
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['UNREAD'] })
841
+ if (mod instanceof Error) return mod
820
842
  await this.invalidateAfterThreadMutation(threadIds)
821
843
  }
822
844
 
@@ -824,7 +846,8 @@ export class GmailClient {
824
846
  const messageIds = await this.getMessageIdsForThreads(threadIds)
825
847
  if (messageIds instanceof Error) return messageIds
826
848
  if (messageIds.length === 0) return
827
- await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
849
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['STARRED'] })
850
+ if (mod instanceof Error) return mod
828
851
  await this.invalidateAfterThreadMutation(threadIds)
829
852
  }
830
853
 
@@ -834,7 +857,8 @@ export class GmailClient {
834
857
  )
835
858
  if (messageIds instanceof Error) return messageIds
836
859
  if (messageIds.length === 0) return
837
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
860
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['STARRED'] })
861
+ if (mod instanceof Error) return mod
838
862
  await this.invalidateAfterThreadMutation(threadIds)
839
863
  }
840
864
 
@@ -861,10 +885,11 @@ export class GmailClient {
861
885
  if (messageIds instanceof Error) return messageIds
862
886
  if (messageIds.length === 0) return
863
887
 
864
- await this.batchModifyMessages(messageIds, {
888
+ const mod = await this.batchModifyMessages(messageIds, {
865
889
  addLabelIds: resolvedAdd.filter((r): r is string => typeof r === 'string'),
866
890
  removeLabelIds: resolvedRemove,
867
891
  })
892
+ if (mod instanceof Error) return mod
868
893
  await this.invalidateAfterThreadMutation(threadIds)
869
894
  }
870
895
 
@@ -892,7 +917,28 @@ export class GmailClient {
892
917
  const messageIds = await this.getMessageIdsForThreads(threadIds)
893
918
  if (messageIds instanceof Error) return messageIds
894
919
  if (messageIds.length === 0) return
895
- await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
920
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['INBOX'] })
921
+ if (mod instanceof Error) return mod
922
+ await this.invalidateAfterThreadMutation(threadIds)
923
+ }
924
+
925
+ async markAsSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
926
+ const messageIds = await this.getMessageIdsForThreads(threadIds)
927
+ if (messageIds instanceof Error) return messageIds
928
+ if (messageIds.length === 0) return
929
+ const mod = await this.batchModifyMessages(messageIds, { addLabelIds: ['SPAM'], removeLabelIds: ['INBOX'] })
930
+ if (mod instanceof Error) return mod
931
+ await this.invalidateAfterThreadMutation(threadIds)
932
+ }
933
+
934
+ async unmarkSpam({ threadIds }: { threadIds: string[] }): Promise<void | AuthError | ApiError> {
935
+ const messageIds = await this.getMessageIdsForThreads(threadIds, (labelIds) =>
936
+ labelIds.includes('SPAM'),
937
+ )
938
+ if (messageIds instanceof Error) return messageIds
939
+ if (messageIds.length === 0) return
940
+ const mod = await this.batchModifyMessages(messageIds, { removeLabelIds: ['SPAM'], addLabelIds: ['INBOX'] })
941
+ if (mod instanceof Error) return mod
896
942
  await this.invalidateAfterThreadMutation(threadIds)
897
943
  }
898
944
 
@@ -919,10 +965,11 @@ export class GmailClient {
919
965
  const threadIds = res.threads.map((t) => t.id)
920
966
  const messageIds = await this.getMessageIdsForThreads(threadIds)
921
967
  if (messageIds instanceof Error) return messageIds
922
- await this.batchModifyMessages(messageIds, {
968
+ const mod = await this.batchModifyMessages(messageIds, {
923
969
  addLabelIds: ['TRASH'],
924
970
  removeLabelIds: ['SPAM', 'INBOX'],
925
971
  })
972
+ if (mod instanceof Error) return mod
926
973
 
927
974
  totalDeleted += threadIds.length
928
975
  pageToken = res.nextPageToken ?? undefined
@@ -1021,6 +1068,61 @@ export class GmailClient {
1021
1068
  await this.invalidateLabels()
1022
1069
  }
1023
1070
 
1071
+ // =========================================================================
1072
+ // Filters
1073
+ // =========================================================================
1074
+
1075
+ async listFilters(): Promise<{ parsed: gmail_v1.Schema$Filter[] } | AuthError | ApiError> {
1076
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1077
+ withRetry(() => this.gmail.users.settings.filters.list({ userId: 'me' })),
1078
+ )
1079
+ if (res instanceof Error) return res
1080
+ return { parsed: res.data.filter ?? [] }
1081
+ }
1082
+
1083
+ async createFilter(opts: {
1084
+ from?: string
1085
+ query?: string
1086
+ addLabelIds?: string[]
1087
+ removeLabelIds?: string[]
1088
+ }): Promise<gmail_v1.Schema$Filter | AuthError | ApiError> {
1089
+ const criteria: gmail_v1.Schema$FilterCriteria = {}
1090
+ if (opts.from) criteria.from = opts.from
1091
+ if (opts.query) criteria.query = opts.query
1092
+
1093
+ const action: gmail_v1.Schema$FilterAction = {}
1094
+ if (opts.addLabelIds?.length) action.addLabelIds = opts.addLabelIds
1095
+ if (opts.removeLabelIds?.length) action.removeLabelIds = opts.removeLabelIds
1096
+
1097
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1098
+ withRetry(() =>
1099
+ this.gmail.users.settings.filters.create({
1100
+ userId: 'me',
1101
+ requestBody: { criteria, action },
1102
+ }),
1103
+ ),
1104
+ )
1105
+ if (res instanceof Error) return res
1106
+ return res.data
1107
+ }
1108
+
1109
+ async deleteFilter(filterId: string): Promise<void | AuthError | ApiError> {
1110
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1111
+ withRetry(() =>
1112
+ this.gmail.users.settings.filters.delete({
1113
+ userId: 'me',
1114
+ id: filterId,
1115
+ }),
1116
+ ),
1117
+ )
1118
+ if (res instanceof Error) return res
1119
+ }
1120
+
1121
+ /** Resolve a label name to its ID, auto-creating if missing. Public wrapper for filter commands. */
1122
+ async resolveLabel(nameOrId: string): Promise<string | AuthError | ApiError> {
1123
+ return this.resolveLabelId(nameOrId)
1124
+ }
1125
+
1024
1126
  // =========================================================================
1025
1127
  // Label counts (unread counts per folder/label)
1026
1128
  // =========================================================================
@@ -1194,6 +1296,20 @@ export class GmailClient {
1194
1296
 
1195
1297
  const { body, mimeType, textBody } = this.extractBody(message.payload ?? {})
1196
1298
 
1299
+ // Authentication-Results: multiple MTAs can add this header. Prefer the one
1300
+ // stamped by Gmail's trusted authserv-id (mx.google.com) to avoid trusting
1301
+ // headers injected by upstream/untrusted relays.
1302
+ const authHeaders = headers
1303
+ .filter((h) => h.name?.toLowerCase() === 'authentication-results')
1304
+ .map((h) => h.value ?? '')
1305
+ .filter((v) => v.length > 0)
1306
+ const trustedAuthHeader =
1307
+ authHeaders.find((v) => v.trim().toLowerCase().startsWith('mx.google.com'))
1308
+ ?? authHeaders[0]
1309
+ ?? null
1310
+ const isSentOrDraft = labelIds.includes('SENT') || labelIds.includes('DRAFT')
1311
+ const auth = trustedAuthHeader && !isSentOrDraft ? parseAuthResults(trustedAuthHeader) : null
1312
+
1197
1313
  return {
1198
1314
  id: message.id ?? '',
1199
1315
  threadId: message.threadId ?? '',
@@ -1220,6 +1336,7 @@ export class GmailClient {
1220
1336
  mimeType,
1221
1337
  textBody,
1222
1338
  attachments: this.extractAttachmentMeta(message.payload?.parts ?? []),
1339
+ auth,
1223
1340
  }
1224
1341
  }
1225
1342
 
@@ -1260,16 +1377,47 @@ export class GmailClient {
1260
1377
  }
1261
1378
  }
1262
1379
 
1380
+ // Parse recipients from latest message
1381
+ const toHeader = getHeader('to') ?? ''
1382
+ const ccHeaders = headers
1383
+ .filter((h) => h.name?.toLowerCase() === 'cc')
1384
+ .map((h) => h.value ?? '')
1385
+ .filter((v) => v.length > 0)
1386
+
1387
+ // Check if any non-draft message in the thread is a reply (has In-Reply-To header)
1388
+ const inReplyTo =
1389
+ nonDraftMessages
1390
+ .map(
1391
+ (m) =>
1392
+ m.payload?.headers?.find((h) => h.name?.toLowerCase() === 'in-reply-to')?.value ??
1393
+ null,
1394
+ )
1395
+ .find((v) => v !== null) ?? null
1396
+
1397
+ // Check if any message has attachments (non-inline)
1398
+ const hasAttachments = messages.some((m) => this.hasNonInlineAttachments(m.payload))
1399
+
1400
+ // List-Unsubscribe from latest message
1401
+ const listUnsubscribe = getHeader('list-unsubscribe') ?? null
1402
+
1263
1403
  return {
1264
1404
  id: raw.id ?? '',
1265
1405
  historyId: raw.historyId ?? null,
1266
1406
  snippet: sanitizeSnippet(latest?.snippet ?? ''),
1267
1407
  subject: (getHeader('subject') ?? '(no subject)').replace(/"/g, '').trim(),
1268
1408
  from: displayFrom,
1409
+ to: toHeader ? parseAddressList(toHeader) : [],
1410
+ cc: ccHeaders.length > 0
1411
+ ? ccHeaders.filter((h) => h.trim().length > 0).flatMap((h) => parseAddressList(h))
1412
+ : [],
1269
1413
  date: getHeader('date') ?? '',
1270
1414
  labelIds: allLabels,
1271
1415
  unread: allLabels.includes('UNREAD'),
1416
+ starred: allLabels.includes('STARRED'),
1272
1417
  messageCount: nonDraftMessages.length,
1418
+ inReplyTo,
1419
+ hasAttachments,
1420
+ listUnsubscribe,
1273
1421
  }
1274
1422
  }
1275
1423
 
@@ -1393,6 +1541,24 @@ export class GmailClient {
1393
1541
  return results
1394
1542
  }
1395
1543
 
1544
+ /** Quick check: does the message payload tree contain any non-inline attachments?
1545
+ * Checks the root part itself (some messages have attachment metadata there)
1546
+ * then recurses into child parts. */
1547
+ private hasNonInlineAttachments(part: gmail_v1.Schema$MessagePart | undefined): boolean {
1548
+ if (!part) return false
1549
+ if (part.filename && part.filename.length > 0 && part.body?.attachmentId) {
1550
+ const disposition =
1551
+ part.headers?.find((h) => h.name?.toLowerCase() === 'content-disposition')?.value ?? ''
1552
+ const hasContentId = part.headers?.some((h) => h.name?.toLowerCase() === 'content-id')
1553
+ const isInline = disposition.toLowerCase().includes('inline')
1554
+ if (!isInline || !hasContentId) return true
1555
+ }
1556
+ for (const child of part.parts ?? []) {
1557
+ if (this.hasNonInlineAttachments(child)) return true
1558
+ }
1559
+ return false
1560
+ }
1561
+
1396
1562
  async getEmailAliases(): Promise<Array<{ email: string; name?: string; primary: boolean }> | AuthError | ApiError> {
1397
1563
  const profile = await this.getProfile()
1398
1564
  if (profile instanceof Error) return profile
@@ -1821,22 +1987,25 @@ export class GmailClient {
1821
1987
  private async batchModifyMessages(
1822
1988
  messageIds: string[],
1823
1989
  body: { addLabelIds?: string[]; removeLabelIds?: string[] },
1824
- ) {
1990
+ ): Promise<void | AuthError | ApiError> {
1825
1991
  if (messageIds.length === 0) return
1826
1992
 
1827
1993
  // Gmail batchModify accepts up to 1000 IDs
1828
1994
  const chunkSize = 1000
1829
1995
  for (let i = 0; i < messageIds.length; i += chunkSize) {
1830
1996
  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
- }),
1997
+ const res = await gmailBoundary(this.account?.email ?? 'unknown', () =>
1998
+ withRetry(() =>
1999
+ this.gmail.users.messages.batchModify({
2000
+ userId: 'me',
2001
+ requestBody: {
2002
+ ids: chunk,
2003
+ ...body,
2004
+ },
2005
+ }),
2006
+ ),
1839
2007
  )
2008
+ if (res instanceof Error) return res
1840
2009
  }
1841
2010
  }
1842
2011
 
@@ -1875,6 +2044,42 @@ export class GmailClient {
1875
2044
  }
1876
2045
  }
1877
2046
 
2047
+ // ---------------------------------------------------------------------------
2048
+ // Email authentication: parse the Authentication-Results header
2049
+ // ---------------------------------------------------------------------------
2050
+ // Gmail adds an Authentication-Results header to every received message with
2051
+ // SPF, DKIM, and DMARC verdicts. Format is semi-structured:
2052
+ // Authentication-Results: mx.google.com;
2053
+ // dkim=pass header.i=@example.com header.s=sel1;
2054
+ // spf=pass (...) smtp.mailfrom=user@example.com;
2055
+ // dmarc=pass (p=REJECT) header.from=example.com
2056
+ // We extract the verdict keyword after each protocol name.
2057
+ // ---------------------------------------------------------------------------
2058
+
2059
+ const AUTH_VERDICTS = new Set(['pass', 'fail', 'softfail', 'neutral', 'none', 'temperror', 'permerror', 'bestguesspass'])
2060
+
2061
+ /** Parse a Gmail Authentication-Results header into structured verdicts. */
2062
+ export function parseAuthResults(header: string): AuthResult {
2063
+ const lower = header.toLowerCase()
2064
+ const extract = (protocol: string): AuthVerdict => {
2065
+ // Match "protocol=verdict" — verdict is a word that stops at whitespace, semicolons, or parens
2066
+ const re = new RegExp(`\\b${protocol}\\s*=\\s*([a-z]+)`)
2067
+ const match = re.exec(lower)
2068
+ const verdict = match?.[1] ?? 'none'
2069
+ return AUTH_VERDICTS.has(verdict) ? verdict as AuthVerdict : 'none'
2070
+ }
2071
+ const spf = extract('spf')
2072
+ const dkim = extract('dkim')
2073
+ const dmarc = extract('dmarc')
2074
+ return {
2075
+ spf,
2076
+ dkim,
2077
+ dmarc,
2078
+ authentic: spf === 'pass' && dkim === 'pass' && dmarc === 'pass',
2079
+ raw: header,
2080
+ }
2081
+ }
2082
+
1878
2083
  // ---------------------------------------------------------------------------
1879
2084
  // Watch: folder label mapping
1880
2085
  // ---------------------------------------------------------------------------