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
@@ -11,11 +11,29 @@ import React from 'react'
11
11
  import { lookup as mimeLookup } from 'mrmime'
12
12
  import { getClients, getClient, listAccounts, login } from '../auth.js'
13
13
  import type { ThreadListResult } from '../gmail-client.js'
14
+ import type { GmailClient } from '../gmail-client.js'
14
15
  import { AuthError } from '../api-utils.js'
15
16
  import * as out from '../output.js'
16
17
  import { handleCommandError } from '../output.js'
17
18
  import pc from 'picocolors'
18
19
 
20
+ // ---------------------------------------------------------------------------
21
+ // Label formatting — filter out system labels already represented by flags
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const HIDDEN_LABELS = new Set([
25
+ 'INBOX', 'SENT', 'TRASH', 'SPAM', 'DRAFT', 'UNREAD', 'STARRED',
26
+ 'IMPORTANT', 'CHAT', 'CATEGORY_PERSONAL', 'CATEGORY_SOCIAL',
27
+ 'CATEGORY_PROMOTIONS', 'CATEGORY_UPDATES', 'CATEGORY_FORUMS',
28
+ ])
29
+
30
+ function formatLabels(labelIds: string[], labelMap?: Map<string, string>): string {
31
+ const visible = labelIds
32
+ .filter((id) => !HIDDEN_LABELS.has(id))
33
+ .map((id) => labelMap?.get(id) ?? id)
34
+ return visible.join(', ')
35
+ }
36
+
19
37
  // ---------------------------------------------------------------------------
20
38
  // Register commands
21
39
  // ---------------------------------------------------------------------------
@@ -34,8 +52,9 @@ export function registerMailCommands(cli: Goke) {
34
52
  .command('mail list', 'List email threads')
35
53
  .option('--folder [folder]', 'Folder to list (inbox, sent, trash, spam, starred, drafts, archive, all) (default: inbox)')
36
54
  .option('--max [max]', 'Max results per page (default: 20)')
37
- .option('--page <page>', 'Pagination token')
55
+ .option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
38
56
  .option('--label <label>', 'Filter by label name')
57
+ .option('--filter <filter>', 'Gmail search filter (e.g. "is:unread", "from:github", "has:attachment")')
39
58
  .action(async (options) => {
40
59
  const folder = options.folder ?? 'inbox'
41
60
  const max = options.max ? Number(options.max) : 20
@@ -46,17 +65,27 @@ export function registerMailCommands(cli: Goke) {
46
65
  process.exit(1)
47
66
  }
48
67
 
49
- // Fetch from all accounts concurrently
68
+ // Fetch threads and labels from all accounts concurrently
50
69
  const results = await Promise.all(
51
- clients.map(async ({ email, client }) => {
70
+ clients.map(async ({ email, client, accountType }) => {
52
71
  const result = await client.listThreads({
53
72
  folder,
54
73
  maxResults: max,
55
74
  labelIds: options.label ? [options.label] : undefined,
56
75
  pageToken: options.page,
76
+ query: options.filter,
57
77
  })
58
78
  if (result instanceof Error) return result
59
- return { email, result }
79
+
80
+ // Labels are Google-only — skip for IMAP accounts
81
+ let labelMap = new Map<string, string>()
82
+ if (accountType === 'google') {
83
+ const labelsResult = await (client as GmailClient).listLabels()
84
+ if (!(labelsResult instanceof Error)) {
85
+ labelMap = new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
86
+ }
87
+ }
88
+ return { email, result, labelMap }
60
89
  }),
61
90
  )
62
91
 
@@ -66,6 +95,10 @@ export function registerMailCommands(cli: Goke) {
66
95
  return true
67
96
  })
68
97
 
98
+ // Merge label maps from all accounts
99
+ const labelMap = new Map<string, string>()
100
+ for (const r of allResults) for (const [id, name] of r.labelMap) labelMap.set(id, name)
101
+
69
102
  // Merge threads from all accounts, sorted by date descending, capped at max
70
103
  const merged = allResults
71
104
  .flatMap(({ email, result }) =>
@@ -81,16 +114,26 @@ export function registerMailCommands(cli: Goke) {
81
114
 
82
115
  const showAccount = clients.length > 1
83
116
  out.printList(
84
- merged.map((t) => ({
85
- ...(showAccount ? { account: t.account } : {}),
86
- id: t.id,
87
- flags: out.formatFlags(t),
88
- from: out.formatSender(t.from),
89
- subject: t.subject,
90
- date: out.formatDate(t.date),
91
- messages: t.messageCount,
92
- })),
93
- { summary: `${merged.length} threads (${folder})` },
117
+ merged.map((t) => {
118
+ const to = t.to.map((s) => out.formatSender(s)).join(', ')
119
+ const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
120
+ const labels = formatLabels(t.labelIds, labelMap)
121
+ return {
122
+ ...(showAccount ? { account: t.account } : {}),
123
+ id: t.id,
124
+ flags: out.formatFlags(t),
125
+ from: out.formatSender(t.from),
126
+ ...(to ? { to } : {}),
127
+ ...(cc ? { cc } : {}),
128
+ subject: t.subject,
129
+ snippet: t.snippet,
130
+ date: out.formatDate(t.date),
131
+ messages: t.messageCount,
132
+ ...(labels ? { labels } : {}),
133
+ ...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
134
+ }
135
+ }),
136
+ { summary: `${merged.length} threads (${folder})`, nextPage: allResults[0]?.result.nextPageToken },
94
137
  )
95
138
  })
96
139
 
@@ -101,7 +144,7 @@ export function registerMailCommands(cli: Goke) {
101
144
  cli
102
145
  .command('mail search <query>', 'Search email threads using Gmail query syntax (from:, to:, subject:, has:attachment, etc). See https://support.google.com/mail/answer/7190')
103
146
  .option('--max [max]', 'Max results (default: 20)')
104
- .option('--page <page>', 'Pagination token')
147
+ .option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
105
148
  .action(async (query, options) => {
106
149
  const max = options.max ? Number(options.max) : 20
107
150
  const clients = await getClients(options.account)
@@ -111,16 +154,24 @@ export function registerMailCommands(cli: Goke) {
111
154
  process.exit(1)
112
155
  }
113
156
 
114
- // Search all accounts concurrently
157
+ // Search all accounts concurrently (fetch labels alongside for name resolution)
115
158
  const results = await Promise.all(
116
- clients.map(async ({ email, client }) => {
159
+ clients.map(async ({ email, client, accountType }) => {
117
160
  const result = await client.listThreads({
118
161
  query,
119
162
  maxResults: max,
120
163
  pageToken: options.page,
121
164
  })
122
165
  if (result instanceof Error) return result
123
- return { email, result }
166
+
167
+ let labelMap = new Map<string, string>()
168
+ if (accountType === 'google') {
169
+ const labelsResult = await (client as GmailClient).listLabels()
170
+ if (!(labelsResult instanceof Error)) {
171
+ labelMap = new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
172
+ }
173
+ }
174
+ return { email, result, labelMap }
124
175
  }),
125
176
  )
126
177
 
@@ -130,6 +181,10 @@ export function registerMailCommands(cli: Goke) {
130
181
  return true
131
182
  })
132
183
 
184
+ // Merge label maps from all accounts
185
+ const labelMap = new Map<string, string>()
186
+ for (const r of allResults) for (const [id, name] of r.labelMap) labelMap.set(id, name)
187
+
133
188
  const merged = allResults
134
189
  .flatMap(({ email, result }) =>
135
190
  result.threads.map((t) => ({ ...t, account: email })),
@@ -144,16 +199,26 @@ export function registerMailCommands(cli: Goke) {
144
199
 
145
200
  const showAccount = clients.length > 1
146
201
  out.printList(
147
- merged.map((t) => ({
148
- ...(showAccount ? { account: t.account } : {}),
149
- id: t.id,
150
- flags: out.formatFlags(t),
151
- from: out.formatSender(t.from),
152
- subject: t.subject,
153
- date: out.formatDate(t.date),
154
- messages: t.messageCount,
155
- })),
156
- { summary: `${merged.length} results for "${query}"` },
202
+ merged.map((t) => {
203
+ const to = t.to.map((s) => out.formatSender(s)).join(', ')
204
+ const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
205
+ const labels = formatLabels(t.labelIds, labelMap)
206
+ return {
207
+ ...(showAccount ? { account: t.account } : {}),
208
+ id: t.id,
209
+ flags: out.formatFlags(t),
210
+ from: out.formatSender(t.from),
211
+ ...(to ? { to } : {}),
212
+ ...(cc ? { cc } : {}),
213
+ subject: t.subject,
214
+ snippet: t.snippet,
215
+ date: out.formatDate(t.date),
216
+ messages: t.messageCount,
217
+ ...(labels ? { labels } : {}),
218
+ ...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
219
+ }
220
+ }),
221
+ { summary: `${merged.length} results for "${query}"`, nextPage: allResults[0]?.result.nextPageToken },
157
222
  )
158
223
  })
159
224
 
@@ -162,10 +227,16 @@ export function registerMailCommands(cli: Goke) {
162
227
  // =========================================================================
163
228
 
164
229
  cli
165
- .command('mail read <threadId>', 'Read a full email thread')
166
- .option('--raw', 'Show raw message (first message only)')
230
+ .command('mail read [...threadIds]', 'Read full email threads (does not mark as read)')
231
+ .option('--raw', 'Show raw message (first message only, single thread)')
167
232
  .option('--raw-html', 'Show raw HTML body per message (no markdown conversion)')
168
- .action(async (threadId, options) => {
233
+ .option('--verify', 'Show expanded email authentication details (SPF/DKIM/DMARC)')
234
+ .action(async (threadIds, options) => {
235
+ if (threadIds.length === 0) {
236
+ out.error('No thread IDs provided')
237
+ process.exit(1)
238
+ }
239
+
169
240
  const { client } = await getClient(options.account)
170
241
 
171
242
  if (options.raw && options.rawHtml) {
@@ -174,7 +245,11 @@ export function registerMailCommands(cli: Goke) {
174
245
  }
175
246
 
176
247
  if (options.raw) {
177
- const { parsed: thread } = await client.getThread({ threadId })
248
+ if (threadIds.length > 1) {
249
+ out.error('--raw only supports a single thread ID')
250
+ process.exit(1)
251
+ }
252
+ const { parsed: thread } = await client.getThread({ threadId: threadIds[0]! })
178
253
  if (thread.messages.length === 0) {
179
254
  out.hint('No messages in thread')
180
255
  return
@@ -185,72 +260,114 @@ export function registerMailCommands(cli: Goke) {
185
260
  return
186
261
  }
187
262
 
188
- const { parsed: thread } = await client.getThread({ threadId })
189
-
190
- if (thread.messages.length === 0) {
191
- out.hint('No messages in thread')
192
- return
193
- }
194
-
195
- if (options.rawHtml) {
196
- thread.messages.forEach((msg, index) => {
197
- console.log(msg.body)
198
- if (index < thread.messages.length - 1) {
199
- console.log('\n<!-- ZELE_MESSAGE_SEPARATOR -->\n')
200
- }
201
- })
202
- return
203
- }
263
+ // Fetch all threads concurrently, tolerating individual failures
264
+ const settled = await Promise.allSettled(
265
+ threadIds.map((id) => client.getThread({ threadId: id })),
266
+ )
204
267
 
205
268
  const w = Math.min(process.stdout.columns || 72, 72)
206
269
  const rule = pc.dim('─'.repeat(w))
270
+ const multi = threadIds.length > 1
207
271
 
208
- // Render thread header
209
- console.log(pc.bold(thread.subject))
210
- // Collect unique participants
211
- const participants = new Map<string, string>()
212
- for (const msg of thread.messages) {
213
- participants.set(msg.from.email, msg.from.name || msg.from.email)
214
- for (const r of msg.to) participants.set(r.email, r.name || r.email)
215
- }
216
- const participantStr = [...participants.values()].join(', ')
217
- console.log(pc.dim(`${thread.messageCount} message(s) · ${participantStr}`))
218
- console.log(pc.dim(`ID: ${thread.id}`))
219
- console.log(rule + '\n')
220
-
221
- // Render each message
222
- for (const msg of thread.messages) {
223
- const fromStr = out.formatSender(msg.from)
224
- const dateStr = out.formatDate(msg.date)
225
-
226
- // Flags as dim tags
227
- const flagParts: string[] = []
228
- if (msg.unread) flagParts.push(pc.yellow('[unread]'))
229
- if (msg.starred) flagParts.push(pc.yellow('[starred]'))
230
- const flagStr = flagParts.length > 0 ? ' ' + flagParts.join(' ') : ''
231
-
232
- console.log(pc.bold(`From: `) + fromStr + flagStr)
233
- console.log(pc.dim(` To: ${msg.to.map((t) => t.email).join(', ')}`))
234
- if (msg.cc && msg.cc.length > 0) {
235
- console.log(pc.dim(` Cc: ${msg.cc.map((c) => c.email).join(', ')}`))
272
+ for (let i = 0; i < settled.length; i++) {
273
+ const result = settled[i]!
274
+
275
+ if (multi) {
276
+ const doubleRule = pc.bold('━'.repeat(w))
277
+ console.log(doubleRule)
278
+ console.log(pc.bold(`Thread ${i + 1}/${settled.length}`) + pc.dim(` · ${threadIds[i]}`))
279
+ console.log(doubleRule)
280
+ console.log()
281
+ }
282
+
283
+ if (result.status === 'rejected') {
284
+ out.error(`Failed to read thread ${threadIds[i]}: ${String(result.reason)}`)
285
+ if (multi) console.log()
286
+ continue
287
+ }
288
+
289
+ const { parsed: thread } = result.value
290
+
291
+ if (thread.messages.length === 0) {
292
+ out.hint('No messages in thread')
293
+ if (multi) console.log()
294
+ continue
236
295
  }
237
- console.log(pc.dim(`Date: ${dateStr}`))
238
-
239
- if (msg.attachments.length > 0) {
240
- const attList = msg.attachments.map((a) => {
241
- const size = a.size < 1024 ? `${a.size} B`
242
- : a.size < 1048576 ? `${(a.size / 1024).toFixed(1)} KB`
243
- : `${(a.size / 1048576).toFixed(1)} MB`
244
- return `${a.filename} (${size})`
296
+
297
+ if (options.rawHtml) {
298
+ thread.messages.forEach((msg, index) => {
299
+ console.log(msg.body)
300
+ if (index < thread.messages.length - 1) {
301
+ console.log('\n<!-- ZELE_MESSAGE_SEPARATOR -->\n')
302
+ }
245
303
  })
246
- console.log(pc.dim(`Attachments: ${attList.join(', ')}`))
304
+ if (multi) console.log()
305
+ continue
247
306
  }
248
307
 
249
- console.log()
308
+ // Render thread header
309
+ console.log(pc.bold(thread.subject))
310
+ const participants = new Map<string, string>()
311
+ for (const msg of thread.messages) {
312
+ participants.set(msg.from.email, msg.from.name || msg.from.email)
313
+ for (const r of msg.to) participants.set(r.email, r.name || r.email)
314
+ }
315
+ const participantStr = [...participants.values()].join(', ')
316
+ console.log(pc.dim(`${thread.messageCount} message(s) · ${participantStr}`))
317
+ console.log(pc.dim(`ID: ${thread.id}`))
318
+ console.log(rule + '\n')
319
+
320
+ // Render each message
321
+ for (const msg of thread.messages) {
322
+ const fromStr = out.formatSender(msg.from)
323
+ const dateStr = out.formatDate(msg.date)
324
+
325
+ const flagParts: string[] = []
326
+ if (msg.unread) flagParts.push(pc.yellow('[unread]'))
327
+ if (msg.starred) flagParts.push(pc.yellow('[starred]'))
328
+ const flagStr = flagParts.length > 0 ? ' ' + flagParts.join(' ') : ''
329
+
330
+ console.log(pc.bold(`From: `) + fromStr + flagStr)
331
+ console.log(pc.dim(` To: ${msg.to.map((t) => t.email).join(', ')}`))
332
+ if (msg.cc && msg.cc.length > 0) {
333
+ console.log(pc.dim(` Cc: ${msg.cc.map((c) => c.email).join(', ')}`))
334
+ }
335
+ console.log(pc.dim(`Date: ${dateStr}`))
250
336
 
251
- const body = out.renderEmailBody(msg.body, msg.mimeType)
252
- console.log(body)
253
- console.log('\n' + rule + '\n')
337
+ if (msg.auth) {
338
+ const check = (verdict: string) => {
339
+ return verdict === 'pass'
340
+ ? pc.green('✓')
341
+ : pc.red('✗')
342
+ }
343
+ const parts = [
344
+ `${check(msg.auth.spf)} SPF`,
345
+ `${check(msg.auth.dkim)} DKIM`,
346
+ `${check(msg.auth.dmarc)} DMARC`,
347
+ ]
348
+ const label = msg.auth.authentic ? pc.green('authentic') : pc.red('UNVERIFIED')
349
+ console.log(`Auth: ${parts.join(' ')} (${label})`)
350
+ if (options.verify) {
351
+ console.log(pc.dim(` Raw: ${msg.auth.raw}`))
352
+ }
353
+ }
354
+
355
+ if (msg.attachments.length > 0) {
356
+ const attList = msg.attachments.map((a) => {
357
+ const size = a.size < 1024 ? `${a.size} B`
358
+ : a.size < 1048576 ? `${(a.size / 1024).toFixed(1)} KB`
359
+ : `${(a.size / 1048576).toFixed(1)} MB`
360
+ return `${a.filename} (${size})`
361
+ })
362
+ console.log(pc.dim(`Attachments: ${attList.join(', ')}`))
363
+ }
364
+
365
+ console.log()
366
+
367
+ const body = out.renderEmailBody(msg.body, msg.mimeType)
368
+ console.log(body)
369
+ console.log('\n' + rule + '\n')
370
+ }
254
371
  }
255
372
  })
256
373
 
@@ -326,6 +443,7 @@ export function registerMailCommands(cli: Goke) {
326
443
  fromEmail: options.from,
327
444
  attachments,
328
445
  })
446
+ if (result instanceof Error) handleCommandError(result)
329
447
 
330
448
  out.printYaml(result)
331
449
  out.success(`Sent to ${options.to}`)
@@ -5,24 +5,30 @@
5
5
 
6
6
  import type { Goke } from 'goke'
7
7
  import { getClients } from '../auth.js'
8
+ import type { GmailClient } from '../gmail-client.js'
8
9
  import { AuthError } from '../api-utils.js'
9
10
  import * as out from '../output.js'
10
11
 
11
12
  export function registerProfileCommands(cli: Goke) {
12
13
  cli
13
- .command('profile', 'Show Gmail account info')
14
+ .command('profile', 'Show account info')
14
15
  .action(async (options) => {
15
16
  const clients = await getClients(options.account)
16
17
 
17
18
  // Fetch all accounts concurrently
18
19
  const allResults = await Promise.all(
19
- clients.map(async ({ client }) => {
20
+ clients.map(async ({ client, accountType }) => {
20
21
  const profile = await client.getProfile()
21
22
  if (profile instanceof Error) return profile
22
- // Always fetch aliases fresh
23
- const aliases = await client.getEmailAliases()
24
- if (aliases instanceof Error) return aliases
25
- return { profile, aliases }
23
+
24
+ if (accountType === 'google') {
25
+ // Google accounts have aliases
26
+ const aliases = await (client as GmailClient).getEmailAliases()
27
+ if (aliases instanceof Error) return aliases
28
+ return { profile, aliases, accountType }
29
+ }
30
+
31
+ return { profile, aliases: [{ email: profile.emailAddress, primary: true }], accountType }
26
32
  }),
27
33
  )
28
34
 
@@ -32,18 +38,22 @@ export function registerProfileCommands(cli: Goke) {
32
38
  return true
33
39
  })
34
40
 
35
- for (const { profile, aliases } of results) {
36
- out.printYaml({
41
+ for (const { profile, aliases, accountType } of results) {
42
+ const data: Record<string, unknown> = {
37
43
  email: profile.emailAddress,
38
- messages_total: profile.messagesTotal,
39
- threads_total: profile.threadsTotal,
40
- history_id: profile.historyId,
41
- aliases: aliases.map((a) => ({
42
- email: a.email,
43
- name: a.name ?? null,
44
- primary: a.primary,
45
- })),
46
- })
44
+ type: accountType,
45
+ }
46
+ if (accountType === 'google') {
47
+ data.messages_total = profile.messagesTotal
48
+ data.threads_total = profile.threadsTotal
49
+ data.history_id = profile.historyId
50
+ }
51
+ data.aliases = aliases.map((a) => ({
52
+ email: a.email,
53
+ name: a.name ?? null,
54
+ primary: a.primary,
55
+ }))
56
+ out.printYaml(data)
47
57
  }
48
58
  })
49
59
  }
package/src/db.ts CHANGED
@@ -58,6 +58,10 @@ async function initializePrisma(): Promise<PrismaClient> {
58
58
  // Run schema.sql — uses CREATE TABLE IF NOT EXISTS so it's idempotent
59
59
  await applySchema(prisma)
60
60
 
61
+ // Add new columns to existing Account tables (idempotent migration).
62
+ // CREATE TABLE IF NOT EXISTS doesn't add columns to pre-existing tables.
63
+ await migrateAccountColumns(prisma)
64
+
61
65
  // Secure database files (owner read/write only)
62
66
  secureDatabase()
63
67
 
@@ -93,6 +97,30 @@ async function applySchema(prisma: PrismaClient): Promise<void> {
93
97
  }
94
98
  }
95
99
 
100
+ /**
101
+ * Idempotent migration: add accountType and capabilities columns to Account
102
+ * if they don't already exist (for DBs created before IMAP/SMTP support).
103
+ * Also backfill existing Google accounts with their default capabilities.
104
+ */
105
+ async function migrateAccountColumns(prisma: PrismaClient): Promise<void> {
106
+ const cols = await prisma.$queryRawUnsafe<Array<{ name: string }>>(`PRAGMA table_info("Account")`)
107
+ const colNames = new Set(cols.map((c) => c.name))
108
+
109
+ if (!colNames.has('accountType')) {
110
+ await prisma.$executeRawUnsafe(`ALTER TABLE "Account" ADD COLUMN "accountType" TEXT NOT NULL DEFAULT 'google'`)
111
+ }
112
+ if (!colNames.has('capabilities')) {
113
+ await prisma.$executeRawUnsafe(`ALTER TABLE "Account" ADD COLUMN "capabilities" TEXT NOT NULL DEFAULT ''`)
114
+ }
115
+
116
+ // Backfill: existing Google accounts should have capabilities set
117
+ await prisma.$executeRawUnsafe(`
118
+ UPDATE "Account"
119
+ SET "capabilities" = 'gmail,calendar,smtp'
120
+ WHERE "accountType" = 'google' AND ("capabilities" = '' OR "capabilities" IS NULL)
121
+ `)
122
+ }
123
+
96
124
  /**
97
125
  * Set restrictive permissions on database files.
98
126
  * SQLite WAL mode creates additional -wal and -shm files that also need protection.
@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
20
20
  "clientVersion": "7.3.0",
21
21
  "engineVersion": "9d6ad21cbbceab97458517b147a6a09ff43aa735",
22
22
  "activeProvider": "sqlite",
23
- "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"./src/generated\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\n// Lifecycle status for account credentials stored in `Account`.\nenum AccountStatus {\n active\n disabled\n}\n\n// Stores one OAuth credential set per (email, appId) pair.\n// appId is the Google OAuth client ID used during login, enabling\n// multiple OAuth apps per email (different quotas, scopes, etc.).\n// Default is the Thunderbird client ID (the original/only client).\nmodel Account {\n email String\n appId String\n accountStatus AccountStatus\n tokens String // JSON-encoded OAuth2 Credentials\n createdAt DateTime\n updatedAt DateTime @updatedAt\n\n threads Thread[]\n labels Label?\n profiles Profile?\n syncStates SyncState[]\n calendarLists CalendarList?\n\n @@id([email, appId])\n}\n\n// Caches hydrated thread payloads per account + thread ID.\n// Used by mail read and post-mutation cache invalidation.\n// rawData stores the raw Google gmail_v1.Schema$Thread response (format: full)\n// so the cache is resilient to changes in our own ThreadData type.\n// Indexed columns are extracted for queryability and display.\nmodel Thread {\n id Int @id @default(autoincrement())\n email String\n appId String\n threadId String\n subject String // extracted for display/search\n snippet String // extracted for display\n fromEmail String // extracted for filtering\n fromName String // extracted for display\n date String // extracted for sorting (RFC2822 from header)\n labelIds String // comma-separated, extracted for filtering\n hasUnread Boolean // extracted for filtering\n msgCount Int // extracted for display\n historyId String? // for sync\n rawData String // raw Google API response JSON (gmail_v1.Schema$Thread)\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@unique([email, appId, threadId])\n}\n\n// Caches label metadata per account (label id/name/type payload).\n// Used by label list/get and related command outputs.\n// rawData stores the raw Google gmail_v1.Schema$Label[] response.\nmodel Label {\n email String\n appId String\n rawData String // raw Google API response JSON (gmail_v1.Schema$Label[])\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId])\n}\n\n// Caches Gmail profile payload per account (totals/history id).\n// Used by profile command and account metadata lookups.\n// Fully flattened — no JSON blob needed, only 4 fields from Google.\nmodel Profile {\n email String\n appId String\n emailAddress String // from Gmail API\n messagesTotal Int // from Gmail API\n threadsTotal Int // from Gmail API\n historyId String // from Gmail API\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId])\n}\n\n// Caches calendar list per account.\n// Used by cal list to avoid fetching calendar metadata on every invocation.\n// rawData stores parsed CalendarListItem[] JSON (not raw tsdav — parsed at write time).\nmodel CalendarList {\n email String\n appId String\n rawData String // JSON blob of CalendarListItem[]\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId])\n}\n\n// Stores persistent per-account sync metadata as generic key/value pairs.\n// Use this for lightweight sync cursors and markers that are not cached API\n// payloads, for example `history_id` (incremental Gmail history cursor),\n// `last_full_sync_at`, or other small account-scoped checkpoints.\nmodel SyncState {\n email String\n appId String\n key String\n value String\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId, key])\n}\n",
23
+ "inlineSchema": "generator client {\n provider = \"prisma-client\"\n output = \"./src/generated\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\n// Lifecycle status for account credentials stored in `Account`.\nenum AccountStatus {\n active\n disabled\n}\n\n// Stores one credential set per (email, appId) pair.\n// appId is the Google OAuth client ID for Google accounts, or 'imap_smtp' for IMAP/SMTP accounts.\n// accountType discriminates the credential format stored in tokens.\n// capabilities is a comma-separated list of features: \"gmail,calendar,smtp\" or \"imap,smtp\".\nmodel Account {\n email String\n appId String\n accountType String @default(\"google\") // \"google\" | \"imap_smtp\"\n capabilities String @default(\"\") // comma-separated: \"gmail,calendar,smtp\" or \"imap,smtp\" or \"imap\"\n accountStatus AccountStatus\n tokens String // JSON: OAuth2 Credentials (google) or ImapSmtpCredentials (imap_smtp)\n createdAt DateTime\n updatedAt DateTime @updatedAt\n\n threads Thread[]\n labels Label?\n profiles Profile?\n syncStates SyncState[]\n calendarLists CalendarList?\n\n @@id([email, appId])\n}\n\n// Caches hydrated thread payloads per account + thread ID.\n// Used by mail read and post-mutation cache invalidation.\n// rawData stores the raw Google gmail_v1.Schema$Thread response (format: full)\n// so the cache is resilient to changes in our own ThreadData type.\n// Indexed columns are extracted for queryability and display.\nmodel Thread {\n id Int @id @default(autoincrement())\n email String\n appId String\n threadId String\n subject String // extracted for display/search\n snippet String // extracted for display\n fromEmail String // extracted for filtering\n fromName String // extracted for display\n date String // extracted for sorting (RFC2822 from header)\n labelIds String // comma-separated, extracted for filtering\n hasUnread Boolean // extracted for filtering\n msgCount Int // extracted for display\n historyId String? // for sync\n rawData String // raw Google API response JSON (gmail_v1.Schema$Thread)\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@unique([email, appId, threadId])\n}\n\n// Caches label metadata per account (label id/name/type payload).\n// Used by label list/get and related command outputs.\n// rawData stores the raw Google gmail_v1.Schema$Label[] response.\nmodel Label {\n email String\n appId String\n rawData String // raw Google API response JSON (gmail_v1.Schema$Label[])\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId])\n}\n\n// Caches Gmail profile payload per account (totals/history id).\n// Used by profile command and account metadata lookups.\n// Fully flattened — no JSON blob needed, only 4 fields from Google.\nmodel Profile {\n email String\n appId String\n emailAddress String // from Gmail API\n messagesTotal Int // from Gmail API\n threadsTotal Int // from Gmail API\n historyId String // from Gmail API\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId])\n}\n\n// Caches calendar list per account.\n// Used by cal list to avoid fetching calendar metadata on every invocation.\n// rawData stores parsed CalendarListItem[] JSON (not raw tsdav — parsed at write time).\nmodel CalendarList {\n email String\n appId String\n rawData String // JSON blob of CalendarListItem[]\n ttlMs Int\n createdAt DateTime\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId])\n}\n\n// Stores persistent per-account sync metadata as generic key/value pairs.\n// Use this for lightweight sync cursors and markers that are not cached API\n// payloads, for example `history_id` (incremental Gmail history cursor),\n// `last_full_sync_at`, or other small account-scoped checkpoints.\nmodel SyncState {\n email String\n appId String\n key String\n value String\n\n account Account @relation(fields: [email, appId], references: [email, appId], onDelete: Cascade)\n\n @@id([email, appId, key])\n}\n",
24
24
  "runtimeDataModel": {
25
25
  "models": {},
26
26
  "enums": {},
@@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = {
28
28
  }
29
29
  }
30
30
 
31
- config.runtimeDataModel = JSON.parse("{\"models\":{\"Account\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountStatus\",\"kind\":\"enum\",\"type\":\"AccountStatus\"},{\"name\":\"tokens\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"threads\",\"kind\":\"object\",\"type\":\"Thread\",\"relationName\":\"AccountToThread\"},{\"name\":\"labels\",\"kind\":\"object\",\"type\":\"Label\",\"relationName\":\"AccountToLabel\"},{\"name\":\"profiles\",\"kind\":\"object\",\"type\":\"Profile\",\"relationName\":\"AccountToProfile\"},{\"name\":\"syncStates\",\"kind\":\"object\",\"type\":\"SyncState\",\"relationName\":\"AccountToSyncState\"},{\"name\":\"calendarLists\",\"kind\":\"object\",\"type\":\"CalendarList\",\"relationName\":\"AccountToCalendarList\"}],\"dbName\":null},\"Thread\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"threadId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subject\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"snippet\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fromEmail\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fromName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"date\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"labelIds\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"hasUnread\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"msgCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"historyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rawData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToThread\"}],\"dbName\":null},\"Label\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rawData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToLabel\"}],\"dbName\":null},\"Profile\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"messagesTotal\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"threadsTotal\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"historyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToProfile\"}],\"dbName\":null},\"CalendarList\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rawData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToCalendarList\"}],\"dbName\":null},\"SyncState\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToSyncState\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
31
+ config.runtimeDataModel = JSON.parse("{\"models\":{\"Account\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountType\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"capabilities\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"accountStatus\",\"kind\":\"enum\",\"type\":\"AccountStatus\"},{\"name\":\"tokens\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"threads\",\"kind\":\"object\",\"type\":\"Thread\",\"relationName\":\"AccountToThread\"},{\"name\":\"labels\",\"kind\":\"object\",\"type\":\"Label\",\"relationName\":\"AccountToLabel\"},{\"name\":\"profiles\",\"kind\":\"object\",\"type\":\"Profile\",\"relationName\":\"AccountToProfile\"},{\"name\":\"syncStates\",\"kind\":\"object\",\"type\":\"SyncState\",\"relationName\":\"AccountToSyncState\"},{\"name\":\"calendarLists\",\"kind\":\"object\",\"type\":\"CalendarList\",\"relationName\":\"AccountToCalendarList\"}],\"dbName\":null},\"Thread\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"threadId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"subject\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"snippet\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fromEmail\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"fromName\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"date\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"labelIds\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"hasUnread\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"msgCount\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"historyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rawData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToThread\"}],\"dbName\":null},\"Label\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rawData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToLabel\"}],\"dbName\":null},\"Profile\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"emailAddress\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"messagesTotal\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"threadsTotal\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"historyId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToProfile\"}],\"dbName\":null},\"CalendarList\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"rawData\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"ttlMs\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToCalendarList\"}],\"dbName\":null},\"SyncState\":{\"fields\":[{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"appId\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"key\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"value\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"account\",\"kind\":\"object\",\"type\":\"Account\",\"relationName\":\"AccountToSyncState\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
32
32
 
33
33
  async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {
34
34
  const { Buffer } = await import('node:buffer')
@@ -892,6 +892,8 @@ export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof
892
892
  export const AccountScalarFieldEnum = {
893
893
  email: 'email',
894
894
  appId: 'appId',
895
+ accountType: 'accountType',
896
+ capabilities: 'capabilities',
895
897
  accountStatus: 'accountStatus',
896
898
  tokens: 'tokens',
897
899
  createdAt: 'createdAt',
@@ -75,6 +75,8 @@ export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof
75
75
  export const AccountScalarFieldEnum = {
76
76
  email: 'email',
77
77
  appId: 'appId',
78
+ accountType: 'accountType',
79
+ capabilities: 'capabilities',
78
80
  accountStatus: 'accountStatus',
79
81
  tokens: 'tokens',
80
82
  createdAt: 'createdAt',