zele 0.3.14 → 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 (56) 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/attachment.js +42 -2
  12. package/dist/commands/attachment.js.map +1 -1
  13. package/dist/commands/auth-cmd.js +1 -1
  14. package/dist/commands/auth-cmd.js.map +1 -1
  15. package/dist/commands/calendar.js +1 -1
  16. package/dist/commands/calendar.js.map +1 -1
  17. package/dist/commands/draft.js +2 -2
  18. package/dist/commands/draft.js.map +1 -1
  19. package/dist/commands/filter.d.ts +2 -0
  20. package/dist/commands/filter.js +59 -0
  21. package/dist/commands/filter.js.map +1 -0
  22. package/dist/commands/mail-actions.js +12 -2
  23. package/dist/commands/mail-actions.js.map +1 -1
  24. package/dist/commands/mail.js +176 -93
  25. package/dist/commands/mail.js.map +1 -1
  26. package/dist/db.js +24 -1
  27. package/dist/db.js.map +1 -1
  28. package/dist/gmail-client.d.ts +28 -0
  29. package/dist/gmail-client.js +168 -13
  30. package/dist/gmail-client.js.map +1 -1
  31. package/dist/mail-tui.js +34 -9
  32. package/dist/mail-tui.js.map +1 -1
  33. package/dist/output.d.ts +2 -0
  34. package/dist/output.js +4 -0
  35. package/dist/output.js.map +1 -1
  36. package/package.json +8 -3
  37. package/skills/zele/SKILL.md +112 -0
  38. package/src/api-utils.ts +7 -0
  39. package/src/app.log +9 -0
  40. package/src/auth.ts +1 -1
  41. package/src/calendar-time.test.ts +35 -0
  42. package/src/calendar-time.ts +5 -0
  43. package/src/cli.ts +6 -1
  44. package/src/commands/attachment.ts +47 -2
  45. package/src/commands/auth-cmd.ts +1 -1
  46. package/src/commands/calendar.ts +1 -1
  47. package/src/commands/draft.ts +2 -2
  48. package/src/commands/filter.ts +68 -0
  49. package/src/commands/mail-actions.ts +14 -2
  50. package/src/commands/mail.ts +186 -98
  51. package/src/db.ts +26 -1
  52. package/src/gmail-client.ts +202 -20
  53. package/src/mail-tui.test.ts +170 -0
  54. package/src/mail-tui.tsx +56 -9
  55. package/src/output.ts +8 -1
  56. package/src/opentui-react.d.ts +0 -9
@@ -16,6 +16,23 @@ import * as out from '../output.js'
16
16
  import { handleCommandError } from '../output.js'
17
17
  import pc from 'picocolors'
18
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // Label formatting — filter out system labels already represented by flags
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const HIDDEN_LABELS = new Set([
24
+ 'INBOX', 'SENT', 'TRASH', 'SPAM', 'DRAFT', 'UNREAD', 'STARRED',
25
+ 'IMPORTANT', 'CHAT', 'CATEGORY_PERSONAL', 'CATEGORY_SOCIAL',
26
+ 'CATEGORY_PROMOTIONS', 'CATEGORY_UPDATES', 'CATEGORY_FORUMS',
27
+ ])
28
+
29
+ function formatLabels(labelIds: string[], labelMap?: Map<string, string>): string {
30
+ const visible = labelIds
31
+ .filter((id) => !HIDDEN_LABELS.has(id))
32
+ .map((id) => labelMap?.get(id) ?? id)
33
+ return visible.join(', ')
34
+ }
35
+
19
36
  // ---------------------------------------------------------------------------
20
37
  // Register commands
21
38
  // ---------------------------------------------------------------------------
@@ -34,8 +51,9 @@ export function registerMailCommands(cli: Goke) {
34
51
  .command('mail list', 'List email threads')
35
52
  .option('--folder [folder]', 'Folder to list (inbox, sent, trash, spam, starred, drafts, archive, all) (default: inbox)')
36
53
  .option('--max [max]', 'Max results per page (default: 20)')
37
- .option('--page <page>', 'Pagination token')
54
+ .option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
38
55
  .option('--label <label>', 'Filter by label name')
56
+ .option('--filter <filter>', 'Gmail search filter (e.g. "is:unread", "from:github", "has:attachment")')
39
57
  .action(async (options) => {
40
58
  const folder = options.folder ?? 'inbox'
41
59
  const max = options.max ? Number(options.max) : 20
@@ -46,17 +64,22 @@ export function registerMailCommands(cli: Goke) {
46
64
  process.exit(1)
47
65
  }
48
66
 
49
- // Fetch from all accounts concurrently
67
+ // Fetch threads and labels from all accounts concurrently
50
68
  const results = await Promise.all(
51
69
  clients.map(async ({ email, client }) => {
52
- const result = await client.listThreads({
53
- folder,
54
- maxResults: max,
55
- labelIds: options.label ? [options.label] : undefined,
56
- pageToken: options.page,
57
- })
70
+ const [result, labelsResult] = await Promise.all([
71
+ client.listThreads({
72
+ folder,
73
+ maxResults: max,
74
+ labelIds: options.label ? [options.label] : undefined,
75
+ pageToken: options.page,
76
+ query: options.filter,
77
+ }),
78
+ client.listLabels(),
79
+ ])
58
80
  if (result instanceof Error) return result
59
- return { email, result }
81
+ const labelMap = labelsResult instanceof Error ? new Map<string, string>() : new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
82
+ return { email, result, labelMap }
60
83
  }),
61
84
  )
62
85
 
@@ -66,6 +89,10 @@ export function registerMailCommands(cli: Goke) {
66
89
  return true
67
90
  })
68
91
 
92
+ // Merge label maps from all accounts
93
+ const labelMap = new Map<string, string>()
94
+ for (const r of allResults) for (const [id, name] of r.labelMap) labelMap.set(id, name)
95
+
69
96
  // Merge threads from all accounts, sorted by date descending, capped at max
70
97
  const merged = allResults
71
98
  .flatMap(({ email, result }) =>
@@ -81,16 +108,26 @@ export function registerMailCommands(cli: Goke) {
81
108
 
82
109
  const showAccount = clients.length > 1
83
110
  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})` },
111
+ merged.map((t) => {
112
+ const to = t.to.map((s) => out.formatSender(s)).join(', ')
113
+ const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
114
+ const labels = formatLabels(t.labelIds, labelMap)
115
+ return {
116
+ ...(showAccount ? { account: t.account } : {}),
117
+ id: t.id,
118
+ flags: out.formatFlags(t),
119
+ from: out.formatSender(t.from),
120
+ ...(to ? { to } : {}),
121
+ ...(cc ? { cc } : {}),
122
+ subject: t.subject,
123
+ snippet: t.snippet,
124
+ date: out.formatDate(t.date),
125
+ messages: t.messageCount,
126
+ ...(labels ? { labels } : {}),
127
+ ...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
128
+ }
129
+ }),
130
+ { summary: `${merged.length} threads (${folder})`, nextPage: allResults[0]?.result.nextPageToken },
94
131
  )
95
132
  })
96
133
 
@@ -101,7 +138,7 @@ export function registerMailCommands(cli: Goke) {
101
138
  cli
102
139
  .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
140
  .option('--max [max]', 'Max results (default: 20)')
104
- .option('--page <page>', 'Pagination token')
141
+ .option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
105
142
  .action(async (query, options) => {
106
143
  const max = options.max ? Number(options.max) : 20
107
144
  const clients = await getClients(options.account)
@@ -111,16 +148,20 @@ export function registerMailCommands(cli: Goke) {
111
148
  process.exit(1)
112
149
  }
113
150
 
114
- // Search all accounts concurrently
151
+ // Search all accounts concurrently (fetch labels alongside for name resolution)
115
152
  const results = await Promise.all(
116
153
  clients.map(async ({ email, client }) => {
117
- const result = await client.listThreads({
118
- query,
119
- maxResults: max,
120
- pageToken: options.page,
121
- })
154
+ const [result, labelsResult] = await Promise.all([
155
+ client.listThreads({
156
+ query,
157
+ maxResults: max,
158
+ pageToken: options.page,
159
+ }),
160
+ client.listLabels(),
161
+ ])
122
162
  if (result instanceof Error) return result
123
- return { email, result }
163
+ const labelMap = labelsResult instanceof Error ? new Map<string, string>() : new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
164
+ return { email, result, labelMap }
124
165
  }),
125
166
  )
126
167
 
@@ -130,6 +171,10 @@ export function registerMailCommands(cli: Goke) {
130
171
  return true
131
172
  })
132
173
 
174
+ // Merge label maps from all accounts
175
+ const labelMap = new Map<string, string>()
176
+ for (const r of allResults) for (const [id, name] of r.labelMap) labelMap.set(id, name)
177
+
133
178
  const merged = allResults
134
179
  .flatMap(({ email, result }) =>
135
180
  result.threads.map((t) => ({ ...t, account: email })),
@@ -144,16 +189,26 @@ export function registerMailCommands(cli: Goke) {
144
189
 
145
190
  const showAccount = clients.length > 1
146
191
  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}"` },
192
+ merged.map((t) => {
193
+ const to = t.to.map((s) => out.formatSender(s)).join(', ')
194
+ const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
195
+ const labels = formatLabels(t.labelIds, labelMap)
196
+ return {
197
+ ...(showAccount ? { account: t.account } : {}),
198
+ id: t.id,
199
+ flags: out.formatFlags(t),
200
+ from: out.formatSender(t.from),
201
+ ...(to ? { to } : {}),
202
+ ...(cc ? { cc } : {}),
203
+ subject: t.subject,
204
+ snippet: t.snippet,
205
+ date: out.formatDate(t.date),
206
+ messages: t.messageCount,
207
+ ...(labels ? { labels } : {}),
208
+ ...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
209
+ }
210
+ }),
211
+ { summary: `${merged.length} results for "${query}"`, nextPage: allResults[0]?.result.nextPageToken },
157
212
  )
158
213
  })
159
214
 
@@ -162,10 +217,15 @@ export function registerMailCommands(cli: Goke) {
162
217
  // =========================================================================
163
218
 
164
219
  cli
165
- .command('mail read <threadId>', 'Read a full email thread')
166
- .option('--raw', 'Show raw message (first message only)')
220
+ .command('mail read [...threadIds]', 'Read full email threads (does not mark as read)')
221
+ .option('--raw', 'Show raw message (first message only, single thread)')
167
222
  .option('--raw-html', 'Show raw HTML body per message (no markdown conversion)')
168
- .action(async (threadId, options) => {
223
+ .action(async (threadIds, options) => {
224
+ if (threadIds.length === 0) {
225
+ out.error('No thread IDs provided')
226
+ process.exit(1)
227
+ }
228
+
169
229
  const { client } = await getClient(options.account)
170
230
 
171
231
  if (options.raw && options.rawHtml) {
@@ -174,7 +234,11 @@ export function registerMailCommands(cli: Goke) {
174
234
  }
175
235
 
176
236
  if (options.raw) {
177
- const { parsed: thread } = await client.getThread({ threadId })
237
+ if (threadIds.length > 1) {
238
+ out.error('--raw only supports a single thread ID')
239
+ process.exit(1)
240
+ }
241
+ const { parsed: thread } = await client.getThread({ threadId: threadIds[0]! })
178
242
  if (thread.messages.length === 0) {
179
243
  out.hint('No messages in thread')
180
244
  return
@@ -185,72 +249,96 @@ export function registerMailCommands(cli: Goke) {
185
249
  return
186
250
  }
187
251
 
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
- }
252
+ // Fetch all threads concurrently, tolerating individual failures
253
+ const settled = await Promise.allSettled(
254
+ threadIds.map((id) => client.getThread({ threadId: id })),
255
+ )
204
256
 
205
257
  const w = Math.min(process.stdout.columns || 72, 72)
206
258
  const rule = pc.dim('─'.repeat(w))
259
+ const multi = threadIds.length > 1
207
260
 
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(', ')}`))
261
+ for (let i = 0; i < settled.length; i++) {
262
+ const result = settled[i]!
263
+
264
+ if (multi) {
265
+ const doubleRule = pc.bold('━'.repeat(w))
266
+ console.log(doubleRule)
267
+ console.log(pc.bold(`Thread ${i + 1}/${settled.length}`) + pc.dim(` · ${threadIds[i]}`))
268
+ console.log(doubleRule)
269
+ console.log()
270
+ }
271
+
272
+ if (result.status === 'rejected') {
273
+ out.error(`Failed to read thread ${threadIds[i]}: ${String(result.reason)}`)
274
+ if (multi) console.log()
275
+ continue
236
276
  }
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})`
277
+
278
+ const { parsed: thread } = result.value
279
+
280
+ if (thread.messages.length === 0) {
281
+ out.hint('No messages in thread')
282
+ if (multi) console.log()
283
+ continue
284
+ }
285
+
286
+ if (options.rawHtml) {
287
+ thread.messages.forEach((msg, index) => {
288
+ console.log(msg.body)
289
+ if (index < thread.messages.length - 1) {
290
+ console.log('\n<!-- ZELE_MESSAGE_SEPARATOR -->\n')
291
+ }
245
292
  })
246
- console.log(pc.dim(`Attachments: ${attList.join(', ')}`))
293
+ if (multi) console.log()
294
+ continue
247
295
  }
248
296
 
249
- console.log()
297
+ // Render thread header
298
+ console.log(pc.bold(thread.subject))
299
+ const participants = new Map<string, string>()
300
+ for (const msg of thread.messages) {
301
+ participants.set(msg.from.email, msg.from.name || msg.from.email)
302
+ for (const r of msg.to) participants.set(r.email, r.name || r.email)
303
+ }
304
+ const participantStr = [...participants.values()].join(', ')
305
+ console.log(pc.dim(`${thread.messageCount} message(s) · ${participantStr}`))
306
+ console.log(pc.dim(`ID: ${thread.id}`))
307
+ console.log(rule + '\n')
308
+
309
+ // Render each message
310
+ for (const msg of thread.messages) {
311
+ const fromStr = out.formatSender(msg.from)
312
+ const dateStr = out.formatDate(msg.date)
313
+
314
+ const flagParts: string[] = []
315
+ if (msg.unread) flagParts.push(pc.yellow('[unread]'))
316
+ if (msg.starred) flagParts.push(pc.yellow('[starred]'))
317
+ const flagStr = flagParts.length > 0 ? ' ' + flagParts.join(' ') : ''
318
+
319
+ console.log(pc.bold(`From: `) + fromStr + flagStr)
320
+ console.log(pc.dim(` To: ${msg.to.map((t) => t.email).join(', ')}`))
321
+ if (msg.cc && msg.cc.length > 0) {
322
+ console.log(pc.dim(` Cc: ${msg.cc.map((c) => c.email).join(', ')}`))
323
+ }
324
+ console.log(pc.dim(`Date: ${dateStr}`))
325
+
326
+ if (msg.attachments.length > 0) {
327
+ const attList = msg.attachments.map((a) => {
328
+ const size = a.size < 1024 ? `${a.size} B`
329
+ : a.size < 1048576 ? `${(a.size / 1024).toFixed(1)} KB`
330
+ : `${(a.size / 1048576).toFixed(1)} MB`
331
+ return `${a.filename} (${size})`
332
+ })
333
+ console.log(pc.dim(`Attachments: ${attList.join(', ')}`))
334
+ }
250
335
 
251
- const body = out.renderEmailBody(msg.body, msg.mimeType)
252
- console.log(body)
253
- console.log('\n' + rule + '\n')
336
+ console.log()
337
+
338
+ const body = out.renderEmailBody(msg.body, msg.mimeType)
339
+ console.log(body)
340
+ console.log('\n' + rule + '\n')
341
+ }
254
342
  }
255
343
  })
256
344
 
package/src/db.ts CHANGED
@@ -37,8 +37,12 @@ export function getPrisma(): Promise<PrismaClient> {
37
37
  }
38
38
 
39
39
  async function initializePrisma(): Promise<PrismaClient> {
40
+ // Create directory with restrictive permissions (owner only)
40
41
  if (!fs.existsSync(ZELE_DIR)) {
41
- fs.mkdirSync(ZELE_DIR, { recursive: true })
42
+ fs.mkdirSync(ZELE_DIR, { recursive: true, mode: 0o700 })
43
+ } else {
44
+ // Ensure existing directory has correct permissions
45
+ fs.chmodSync(ZELE_DIR, 0o700)
42
46
  }
43
47
 
44
48
  const adapter = new PrismaLibSql({ url: `file:${DB_PATH}` })
@@ -54,6 +58,9 @@ async function initializePrisma(): Promise<PrismaClient> {
54
58
  // Run schema.sql — uses CREATE TABLE IF NOT EXISTS so it's idempotent
55
59
  await applySchema(prisma)
56
60
 
61
+ // Secure database files (owner read/write only)
62
+ secureDatabase()
63
+
57
64
  prismaInstance = prisma
58
65
  return prisma
59
66
  }
@@ -86,6 +93,24 @@ async function applySchema(prisma: PrismaClient): Promise<void> {
86
93
  }
87
94
  }
88
95
 
96
+ /**
97
+ * Set restrictive permissions on database files.
98
+ * SQLite WAL mode creates additional -wal and -shm files that also need protection.
99
+ */
100
+ function secureDatabase(): void {
101
+ const filesToSecure = [
102
+ DB_PATH,
103
+ `${DB_PATH}-wal`,
104
+ `${DB_PATH}-shm`,
105
+ ]
106
+
107
+ for (const filePath of filesToSecure) {
108
+ if (fs.existsSync(filePath)) {
109
+ fs.chmodSync(filePath, 0o600)
110
+ }
111
+ }
112
+ }
113
+
89
114
  /**
90
115
  * Close the Prisma connection.
91
116
  */