zele 0.3.16 → 0.3.20

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 (90) hide show
  1. package/README.md +155 -36
  2. package/dist/api-utils.d.ts +14 -0
  3. package/dist/api-utils.js +20 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.d.ts +71 -9
  6. package/dist/auth.js +186 -10
  7. package/dist/auth.js.map +1 -1
  8. package/dist/cli-types.d.ts +4 -0
  9. package/dist/cli-types.js +6 -0
  10. package/dist/cli-types.js.map +1 -0
  11. package/dist/cli.js +1 -5
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/attachment.d.ts +2 -2
  14. package/dist/commands/attachment.js +2 -0
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.d.ts +2 -2
  17. package/dist/commands/auth-cmd.js +104 -6
  18. package/dist/commands/auth-cmd.js.map +1 -1
  19. package/dist/commands/calendar.d.ts +2 -2
  20. package/dist/commands/calendar.js.map +1 -1
  21. package/dist/commands/draft.d.ts +2 -2
  22. package/dist/commands/draft.js +58 -4
  23. package/dist/commands/draft.js.map +1 -1
  24. package/dist/commands/filter.d.ts +2 -2
  25. package/dist/commands/filter.js +7 -2
  26. package/dist/commands/filter.js.map +1 -1
  27. package/dist/commands/label.d.ts +2 -2
  28. package/dist/commands/label.js +19 -9
  29. package/dist/commands/label.js.map +1 -1
  30. package/dist/commands/mail-actions.d.ts +2 -2
  31. package/dist/commands/mail-actions.js +290 -1
  32. package/dist/commands/mail-actions.js.map +1 -1
  33. package/dist/commands/mail.d.ts +2 -2
  34. package/dist/commands/mail.js +90 -23
  35. package/dist/commands/mail.js.map +1 -1
  36. package/dist/commands/profile.d.ts +2 -2
  37. package/dist/commands/profile.js +25 -18
  38. package/dist/commands/profile.js.map +1 -1
  39. package/dist/commands/watch.d.ts +2 -2
  40. package/dist/commands/watch.js.map +1 -1
  41. package/dist/db.js +24 -0
  42. package/dist/db.js.map +1 -1
  43. package/dist/generated/internal/class.js +2 -2
  44. package/dist/generated/internal/class.js.map +1 -1
  45. package/dist/generated/internal/prismaNamespace.d.ts +2 -0
  46. package/dist/generated/internal/prismaNamespace.js +2 -0
  47. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  48. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
  49. package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
  50. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  51. package/dist/generated/models/Account.d.ts +97 -1
  52. package/dist/gmail-client.d.ts +73 -3
  53. package/dist/gmail-client.js +165 -5
  54. package/dist/gmail-client.js.map +1 -1
  55. package/dist/imap-smtp-client.d.ts +306 -0
  56. package/dist/imap-smtp-client.js +1349 -0
  57. package/dist/imap-smtp-client.js.map +1 -0
  58. package/dist/mail-tui.js.map +1 -1
  59. package/dist/unsubscribe.d.ts +76 -0
  60. package/dist/unsubscribe.js +224 -0
  61. package/dist/unsubscribe.js.map +1 -0
  62. package/package.json +6 -3
  63. package/schema.prisma +7 -5
  64. package/skills/zele/SKILL.md +26 -96
  65. package/src/api-utils.ts +20 -0
  66. package/src/auth.ts +282 -14
  67. package/src/cli-types.ts +8 -0
  68. package/src/cli.ts +2 -7
  69. package/src/commands/attachment.ts +3 -2
  70. package/src/commands/auth-cmd.ts +114 -8
  71. package/src/commands/calendar.ts +2 -2
  72. package/src/commands/draft.ts +65 -6
  73. package/src/commands/filter.ts +11 -5
  74. package/src/commands/label.ts +24 -13
  75. package/src/commands/mail-actions.ts +317 -5
  76. package/src/commands/mail.ts +97 -25
  77. package/src/commands/profile.ts +29 -19
  78. package/src/commands/watch.ts +2 -2
  79. package/src/db.ts +28 -0
  80. package/src/generated/internal/class.ts +2 -2
  81. package/src/generated/internal/prismaNamespace.ts +2 -0
  82. package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
  83. package/src/generated/models/Account.ts +97 -1
  84. package/src/gmail-client.test.ts +155 -2
  85. package/src/gmail-client.ts +258 -6
  86. package/src/imap-smtp-client.ts +1560 -0
  87. package/src/mail-tui.tsx +2 -1
  88. package/src/schema.sql +2 -0
  89. package/src/unsubscribe.test.ts +487 -0
  90. package/src/unsubscribe.ts +255 -0
@@ -1,10 +1,19 @@
1
- // Mail action commands: star, unstar, archive, trash, untrash, mark read/unread, spam, unspam, label modify.
1
+ // Mail action commands: star, unstar, archive, trash, untrash, mark read/unread,
2
+ // spam, unspam, label modify, unsubscribe.
2
3
  // Bulk operations on threads — cache invalidation is handled by the client methods.
3
4
 
4
- import type { Goke } from 'goke'
5
+ import type { ZeleCli } from '../cli-types.js'
5
6
  import { z } from 'zod'
7
+ import * as errore from 'errore'
6
8
  import { getClient } from '../auth.js'
7
- import type { GmailClient } from '../gmail-client.js'
9
+ import type { GmailClient, ParsedMessage } from '../gmail-client.js'
10
+ import type { ImapSmtpClient } from '../imap-smtp-client.js'
11
+ import { UnsubscribeUnavailableError, UnsubscribeFailedError } from '../api-utils.js'
12
+ import {
13
+ planUnsubscribe,
14
+ type UnsubscribeMechanism,
15
+ type UnsubscribePlan,
16
+ } from '../unsubscribe.js'
8
17
  import * as out from '../output.js'
9
18
  import { handleCommandError } from '../output.js'
10
19
 
@@ -16,7 +25,7 @@ async function bulkAction(
16
25
  threadIds: string[],
17
26
  actionName: string,
18
27
  accountFilter: string[] | undefined,
19
- fn: (client: GmailClient, ids: string[]) => Promise<void | Error>,
28
+ fn: (client: GmailClient | ImapSmtpClient, ids: string[]) => Promise<void | Error>,
20
29
  ) {
21
30
  if (threadIds.length === 0) {
22
31
  out.error('No thread IDs provided')
@@ -34,7 +43,7 @@ async function bulkAction(
34
43
  // Register commands
35
44
  // ---------------------------------------------------------------------------
36
45
 
37
- export function registerMailActionCommands(cli: Goke) {
46
+ export function registerMailActionCommands(cli: ZeleCli) {
38
47
  cli
39
48
  .command('mail star [...threadIds]', 'Star threads')
40
49
  .action(async (threadIds, options) => {
@@ -120,4 +129,307 @@ export function registerMailActionCommands(cli: Goke) {
120
129
  out.printYaml(result)
121
130
  out.success(`Trashed ${result.count} spam thread(s)`)
122
131
  })
132
+
133
+ // =========================================================================
134
+ // mail unsubscribe — RFC 2369 + RFC 8058
135
+ // =========================================================================
136
+ //
137
+ // Reads List-Unsubscribe and List-Unsubscribe-Post from the latest non-draft
138
+ // message in a thread, then picks a mechanism:
139
+ // 1. RFC 8058 one-click (HTTPS POST with `List-Unsubscribe=One-Click`)
140
+ // 2. RFC 2369 mailto: (send the canonical unsubscribe email)
141
+ // 3. RFC 2369 http(s): landing page (manual — print URL only)
142
+ //
143
+ // The decision logic lives in ../unsubscribe.ts so it can be unit-tested
144
+ // with inline snapshots. This command is the thin executor: fetch thread,
145
+ // build plan, optionally dry-run, otherwise perform the chosen mechanism.
146
+
147
+ cli
148
+ .command('mail unsubscribe <threadId>', 'Unsubscribe from a mailing list thread (RFC 2369 / RFC 8058)')
149
+ .option('--via <via>', z.enum(['auto', 'one-click', 'mailto', 'url']).describe(
150
+ 'Mechanism to use (default: auto — prefers one-click if DKIM passes, then mailto, then url)',
151
+ ))
152
+ .option('--dry-run', 'Print the unsubscribe plan without executing anything')
153
+ .option('--require-dkim', 'Refuse one-click unless the message has DKIM=pass')
154
+ .option('--then <then>', z.enum(['nothing', 'archive', 'trash']).describe(
155
+ 'Follow-up action on the thread after unsubscribing (default: nothing)',
156
+ ))
157
+ .action(async (threadId, options) => {
158
+ const via = options.via ?? 'auto'
159
+ const then = options.then ?? 'nothing'
160
+
161
+ const { client } = await getClient(options.account)
162
+ const { parsed: thread } = await client.getThread({ threadId })
163
+
164
+ const nonDraft = thread.messages.filter((m) => !m.isDraft)
165
+ const latest: ParsedMessage | undefined = nonDraft[nonDraft.length - 1] ?? thread.messages[thread.messages.length - 1]
166
+ if (!latest) {
167
+ handleCommandError(new UnsubscribeUnavailableError({ threadId }))
168
+ }
169
+
170
+ // DKIM gating: per RFC 8058 §4 the signed headers SHOULD include
171
+ // List-Unsubscribe and List-Unsubscribe-Post. We can't verify the
172
+ // `h=` tag here, so we approximate with the MTA's DKIM verdict
173
+ // (`auth.dkim === 'pass'`). SPF and DMARC aren't relevant for this
174
+ // specific header-signing check, so we don't require `auth.authentic`.
175
+ const dkimPass: boolean | null = latest.auth ? latest.auth.dkim === 'pass' : null
176
+ const plan = planUnsubscribe({
177
+ listUnsubscribe: latest.listUnsubscribe,
178
+ listUnsubscribePost: latest.listUnsubscribePost,
179
+ dkimAuthentic: dkimPass,
180
+ })
181
+
182
+ if (plan.mechanisms.length === 0) {
183
+ handleCommandError(new UnsubscribeUnavailableError({ threadId }))
184
+ }
185
+
186
+ const chosen = pickMechanism(plan, via, dkimPass)
187
+ if (chosen instanceof Error) handleCommandError(chosen)
188
+
189
+ if (options.requireDkim && chosen.kind === 'one-click' && dkimPass !== true) {
190
+ handleCommandError(
191
+ new UnsubscribeFailedError({
192
+ mechanism: 'one-click',
193
+ reason:
194
+ dkimPass === false
195
+ ? 'DKIM did not pass and --require-dkim was set'
196
+ : 'DKIM status unknown and --require-dkim was set',
197
+ }),
198
+ )
199
+ }
200
+
201
+ if (options.dryRun) {
202
+ out.printYaml({
203
+ action: 'Unsubscribe (dry-run)',
204
+ thread_id: threadId,
205
+ chosen: describeMechanism(chosen),
206
+ plan: describePlan(plan),
207
+ })
208
+ return
209
+ }
210
+
211
+ // Execute the chosen mechanism.
212
+ if (chosen.kind === 'one-click') {
213
+ const res = await oneClickPost(chosen.url)
214
+ if (res instanceof Error) handleCommandError(res)
215
+ } else if (chosen.kind === 'mailto') {
216
+ const sendResult = await client.sendMessage({
217
+ to: [{ email: chosen.mailto.to }],
218
+ subject: chosen.mailto.subject ?? 'unsubscribe',
219
+ body: chosen.mailto.body ?? 'unsubscribe',
220
+ cc: chosen.mailto.cc?.map((email) => ({ email })),
221
+ })
222
+ if (sendResult instanceof Error) {
223
+ handleCommandError(
224
+ new UnsubscribeFailedError({
225
+ mechanism: 'mailto',
226
+ reason: sendResult.message,
227
+ cause: sendResult,
228
+ }),
229
+ )
230
+ }
231
+ } else {
232
+ // url: cannot be executed programmatically — surface the URL and exit
233
+ // without marking success (nothing was actually done).
234
+ out.printYaml({
235
+ action: 'Unsubscribe (manual required)',
236
+ thread_id: threadId,
237
+ url: chosen.url,
238
+ note: 'Only a landing-page URL is available. Open it in a browser to complete unsubscription.',
239
+ plan: describePlan(plan),
240
+ })
241
+ out.hint('Open the printed URL in a browser to finish unsubscribing.')
242
+ return
243
+ }
244
+
245
+ // Unsubscribe action itself succeeded at this point. Report that
246
+ // BEFORE attempting the follow-up so that a follow-up failure can't
247
+ // hide an already-completed (and irreversible) unsubscribe.
248
+ out.printYaml({
249
+ action: 'Unsubscribed',
250
+ thread_id: threadId,
251
+ mechanism: describeMechanism(chosen),
252
+ then,
253
+ plan: describePlan(plan),
254
+ })
255
+ out.success(`Unsubscribed via ${chosen.kind}`)
256
+
257
+ // Optional follow-up action on the thread. Failure here is non-fatal
258
+ // for the unsubscribe itself — warn and exit non-zero so scripts can
259
+ // still notice, but don't hide the success.
260
+ if (then === 'archive') {
261
+ const r = await client.archive({ threadIds: [threadId] })
262
+ if (r instanceof Error) {
263
+ out.error(`Unsubscribed, but follow-up archive failed: ${r.message}`)
264
+ process.exit(1)
265
+ }
266
+ } else if (then === 'trash') {
267
+ const r = await client.trash({ threadId })
268
+ if (r instanceof Error) {
269
+ out.error(`Unsubscribed, but follow-up trash failed: ${r.message}`)
270
+ process.exit(1)
271
+ }
272
+ }
273
+ })
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // Unsubscribe helpers
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Pick a mechanism from a plan based on the --via flag.
281
+ *
282
+ * In `auto` mode we only use one-click if DKIM is known to pass, so a
283
+ * spoofed List-Unsubscribe-Post header on an unauthenticated message
284
+ * can't trigger a background POST. Users can still force it with
285
+ * `--via one-click` (and can combine with `--require-dkim` to re-gate). */
286
+ function pickMechanism(
287
+ plan: UnsubscribePlan,
288
+ via: 'auto' | 'one-click' | 'mailto' | 'url',
289
+ dkimPass: boolean | null,
290
+ ): UnsubscribeMechanism | UnsubscribeFailedError {
291
+ if (via === 'auto') {
292
+ for (const m of plan.mechanisms) {
293
+ if (m.kind === 'one-click' && dkimPass !== true) continue
294
+ return m
295
+ }
296
+ // Nothing usable in auto mode — the only remaining case is a plan made
297
+ // up entirely of one-click entries on a message we couldn't verify.
298
+ if (plan.mechanisms.length === 0) {
299
+ return new UnsubscribeFailedError({ mechanism: 'auto', reason: 'no mechanisms available' })
300
+ }
301
+ return new UnsubscribeFailedError({
302
+ mechanism: 'auto',
303
+ reason:
304
+ 'only one-click is advertised, but DKIM did not pass. Re-run with --via one-click to force it.',
305
+ })
306
+ }
307
+ const match = plan.mechanisms.find((m) => m.kind === via)
308
+ if (match) return match
309
+ return new UnsubscribeFailedError({
310
+ mechanism: via,
311
+ reason: `no ${via} mechanism advertised by the sender`,
312
+ })
313
+ }
314
+
315
+ /** Reject URLs that point at localhost, loopback, or RFC1918 / link-local /
316
+ * ULA ranges. This is a best-effort SSRF guard — it won't catch DNS
317
+ * rebinding or hostnames that resolve to private IPs, but it stops the
318
+ * obvious cases of `https://127.0.0.1/unsubscribe` hidden in a spoofed
319
+ * List-Unsubscribe header. */
320
+ function isPrivateOrLoopbackHost(hostname: string): boolean {
321
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, '')
322
+ if (h === 'localhost' || h.endsWith('.localhost') || h === 'ip6-localhost') return true
323
+
324
+ // IPv4 literal
325
+ const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
326
+ if (v4) {
327
+ const [a, b] = [Number(v4[1]), Number(v4[2])]
328
+ if (a === 10) return true
329
+ if (a === 127) return true
330
+ if (a === 0) return true
331
+ if (a === 169 && b === 254) return true // link-local
332
+ if (a === 172 && b >= 16 && b <= 31) return true
333
+ if (a === 192 && b === 168) return true
334
+ return false
335
+ }
336
+
337
+ // IPv6 literal — reject loopback (::1), link-local (fe80::/10), ULA (fc00::/7).
338
+ if (h === '::1' || h === '0:0:0:0:0:0:0:1') return true
339
+ if (/^fe[89ab][0-9a-f]:/i.test(h)) return true
340
+ if (/^f[cd][0-9a-f]{2}:/i.test(h)) return true
341
+ return false
342
+ }
343
+
344
+ /** Build an RFC 8058 one-click POST request and validate the response.
345
+ * Returns void on success, UnsubscribeFailedError on any failure. */
346
+ async function oneClickPost(url: string): Promise<void | UnsubscribeFailedError> {
347
+ // Re-validate the URL at the executor boundary (not just the planner),
348
+ // in case a future code path constructs a mechanism without going
349
+ // through planUnsubscribe.
350
+ let parsed: URL
351
+ try {
352
+ parsed = new URL(url)
353
+ } catch (err) {
354
+ return new UnsubscribeFailedError({
355
+ mechanism: 'one-click',
356
+ reason: `invalid URL: ${String(err)}`,
357
+ cause: err,
358
+ })
359
+ }
360
+ if (parsed.protocol !== 'https:') {
361
+ return new UnsubscribeFailedError({
362
+ mechanism: 'one-click',
363
+ reason: `refusing to POST to ${parsed.protocol} URL (RFC 8058 requires https)`,
364
+ })
365
+ }
366
+ if (isPrivateOrLoopbackHost(parsed.hostname)) {
367
+ return new UnsubscribeFailedError({
368
+ mechanism: 'one-click',
369
+ reason: `refusing to POST to private/loopback host ${parsed.hostname} (SSRF guard)`,
370
+ })
371
+ }
372
+
373
+ // RFC 8058 §3.1: senders MUST NOT return redirects, so we refuse to follow.
374
+ // `redirect: 'manual'` lets us see 3xx status codes instead of auto-following.
375
+ // 10-second timeout keeps a slow/unreachable endpoint from hanging the CLI.
376
+ const res = await errore.tryAsync({
377
+ try: () =>
378
+ fetch(parsed, {
379
+ method: 'POST',
380
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
381
+ body: 'List-Unsubscribe=One-Click',
382
+ redirect: 'manual',
383
+ signal: AbortSignal.timeout(10_000),
384
+ }),
385
+ catch: (err) =>
386
+ new UnsubscribeFailedError({
387
+ mechanism: 'one-click',
388
+ reason: String(err),
389
+ cause: err,
390
+ }),
391
+ })
392
+ if (res instanceof Error) return res
393
+
394
+ if (res.status >= 200 && res.status < 300) return undefined
395
+ if (res.status >= 300 && res.status < 400) {
396
+ // RFC 8058 §3.1 says senders MUST NOT redirect, but many widely-deployed
397
+ // senders (ConvertKit, SendGrid, Mailchimp) redirect to a "you have been
398
+ // unsubscribed" confirmation page after processing the POST body. A POST
399
+ // that was going to be rejected would return 4xx, not 3xx — the server
400
+ // has to read and act on the body before deciding to redirect — so we
401
+ // treat 3xx as success with a warning printed to stderr.
402
+ const location = res.headers.get('location')
403
+ out.hint(
404
+ `one-click endpoint returned HTTP ${res.status} redirect${location ? ` → ${location}` : ''} (RFC 8058 §3.1 forbids this, but many senders do it anyway). Treating as success.`,
405
+ )
406
+ return undefined
407
+ }
408
+ return new UnsubscribeFailedError({
409
+ mechanism: 'one-click',
410
+ reason: `HTTP ${res.status} ${res.statusText}`,
411
+ })
412
+ }
413
+
414
+ /** YAML-friendly representation of a mechanism for output. */
415
+ function describeMechanism(m: UnsubscribeMechanism): Record<string, unknown> {
416
+ if (m.kind === 'one-click') return { kind: 'one-click', url: m.url }
417
+ if (m.kind === 'url') return { kind: 'url', url: m.url }
418
+ return {
419
+ kind: 'mailto',
420
+ to: m.mailto.to,
421
+ ...(m.mailto.subject ? { subject: m.mailto.subject } : {}),
422
+ ...(m.mailto.body ? { body: m.mailto.body } : {}),
423
+ ...(m.mailto.cc && m.mailto.cc.length > 0 ? { cc: m.mailto.cc } : {}),
424
+ }
425
+ }
426
+
427
+ /** YAML-friendly representation of a full plan. */
428
+ function describePlan(plan: UnsubscribePlan): Record<string, unknown> {
429
+ return {
430
+ mechanisms: plan.mechanisms.map(describeMechanism),
431
+ has_one_click: plan.hasOneClick,
432
+ dkim_authentic: plan.dkimAuthentic,
433
+ ...(plan.warnings.length > 0 ? { warnings: plan.warnings } : {}),
434
+ }
123
435
  }
@@ -3,7 +3,7 @@
3
3
  // Cache is handled by the client — commands just call methods and use data.
4
4
  // Multi-account: list/search fetch all accounts concurrently and merge by date.
5
5
 
6
- import type { Goke } from 'goke'
6
+ import type { ZeleCli } from '../cli-types.js'
7
7
  import { z } from 'zod'
8
8
  import fs from 'node:fs'
9
9
  import path from 'node:path'
@@ -11,7 +11,9 @@ 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'
16
+ import { hasUnsubscribeMechanism, hasOneClickUnsubscribe } from '../unsubscribe.js'
15
17
  import * as out from '../output.js'
16
18
  import { handleCommandError } from '../output.js'
17
19
  import pc from 'picocolors'
@@ -37,7 +39,7 @@ function formatLabels(labelIds: string[], labelMap?: Map<string, string>): strin
37
39
  // Register commands
38
40
  // ---------------------------------------------------------------------------
39
41
 
40
- export function registerMailCommands(cli: Goke) {
42
+ export function registerMailCommands(cli: ZeleCli) {
41
43
  // =========================================================================
42
44
  // mail (TUI)
43
45
  // =========================================================================
@@ -55,7 +57,9 @@ export function registerMailCommands(cli: Goke) {
55
57
  .option('--label <label>', 'Filter by label name')
56
58
  .option('--filter <filter>', 'Gmail search filter (e.g. "is:unread", "from:github", "has:attachment")')
57
59
  .action(async (options) => {
58
- const folder = options.folder ?? 'inbox'
60
+ // `options.folder` / `options.max` are `string | undefined` now.
61
+ // `''` (bare flag) falls back to the default via `||`.
62
+ const folder = options.folder || 'inbox'
59
63
  const max = options.max ? Number(options.max) : 20
60
64
  const clients = await getClients(options.account)
61
65
 
@@ -66,19 +70,24 @@ export function registerMailCommands(cli: Goke) {
66
70
 
67
71
  // Fetch threads and labels from all accounts concurrently
68
72
  const results = await Promise.all(
69
- clients.map(async ({ email, client }) => {
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
- ])
73
+ clients.map(async ({ email, client, accountType }) => {
74
+ const result = await client.listThreads({
75
+ folder,
76
+ maxResults: max,
77
+ labelIds: options.label ? [options.label] : undefined,
78
+ pageToken: options.page,
79
+ query: options.filter,
80
+ })
80
81
  if (result instanceof Error) return result
81
- const labelMap = labelsResult instanceof Error ? new Map<string, string>() : new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
82
+
83
+ // Labels are Google-only — skip for IMAP accounts
84
+ let labelMap = new Map<string, string>()
85
+ if (accountType === 'google') {
86
+ const labelsResult = await (client as GmailClient).listLabels()
87
+ if (!(labelsResult instanceof Error)) {
88
+ labelMap = new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
89
+ }
90
+ }
82
91
  return { email, result, labelMap }
83
92
  }),
84
93
  )
@@ -112,6 +121,8 @@ export function registerMailCommands(cli: Goke) {
112
121
  const to = t.to.map((s) => out.formatSender(s)).join(', ')
113
122
  const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
114
123
  const labels = formatLabels(t.labelIds, labelMap)
124
+ const canUnsubscribe = hasUnsubscribeMechanism(t.listUnsubscribe)
125
+ const oneClick = hasOneClickUnsubscribe(t.listUnsubscribe, t.listUnsubscribePost)
115
126
  return {
116
127
  ...(showAccount ? { account: t.account } : {}),
117
128
  id: t.id,
@@ -124,6 +135,8 @@ export function registerMailCommands(cli: Goke) {
124
135
  date: out.formatDate(t.date),
125
136
  messages: t.messageCount,
126
137
  ...(labels ? { labels } : {}),
138
+ ...(canUnsubscribe ? { can_unsubscribe: true } : {}),
139
+ ...(oneClick ? { one_click: true } : {}),
127
140
  ...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
128
141
  }
129
142
  }),
@@ -150,17 +163,21 @@ export function registerMailCommands(cli: Goke) {
150
163
 
151
164
  // Search all accounts concurrently (fetch labels alongside for name resolution)
152
165
  const results = await Promise.all(
153
- clients.map(async ({ email, client }) => {
154
- const [result, labelsResult] = await Promise.all([
155
- client.listThreads({
156
- query,
157
- maxResults: max,
158
- pageToken: options.page,
159
- }),
160
- client.listLabels(),
161
- ])
166
+ clients.map(async ({ email, client, accountType }) => {
167
+ const result = await client.listThreads({
168
+ query,
169
+ maxResults: max,
170
+ pageToken: options.page,
171
+ })
162
172
  if (result instanceof Error) return result
163
- const labelMap = labelsResult instanceof Error ? new Map<string, string>() : new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
173
+
174
+ let labelMap = new Map<string, string>()
175
+ if (accountType === 'google') {
176
+ const labelsResult = await (client as GmailClient).listLabels()
177
+ if (!(labelsResult instanceof Error)) {
178
+ labelMap = new Map(labelsResult.parsed.map((l) => [l.id, l.name]))
179
+ }
180
+ }
164
181
  return { email, result, labelMap }
165
182
  }),
166
183
  )
@@ -193,6 +210,8 @@ export function registerMailCommands(cli: Goke) {
193
210
  const to = t.to.map((s) => out.formatSender(s)).join(', ')
194
211
  const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
195
212
  const labels = formatLabels(t.labelIds, labelMap)
213
+ const canUnsubscribe = hasUnsubscribeMechanism(t.listUnsubscribe)
214
+ const oneClick = hasOneClickUnsubscribe(t.listUnsubscribe, t.listUnsubscribePost)
196
215
  return {
197
216
  ...(showAccount ? { account: t.account } : {}),
198
217
  id: t.id,
@@ -205,6 +224,8 @@ export function registerMailCommands(cli: Goke) {
205
224
  date: out.formatDate(t.date),
206
225
  messages: t.messageCount,
207
226
  ...(labels ? { labels } : {}),
227
+ ...(canUnsubscribe ? { can_unsubscribe: true } : {}),
228
+ ...(oneClick ? { one_click: true } : {}),
208
229
  ...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
209
230
  }
210
231
  }),
@@ -220,6 +241,7 @@ export function registerMailCommands(cli: Goke) {
220
241
  .command('mail read [...threadIds]', 'Read full email threads (does not mark as read)')
221
242
  .option('--raw', 'Show raw message (first message only, single thread)')
222
243
  .option('--raw-html', 'Show raw HTML body per message (no markdown conversion)')
244
+ .option('--verify', 'Show expanded email authentication details (SPF/DKIM/DMARC)')
223
245
  .action(async (threadIds, options) => {
224
246
  if (threadIds.length === 0) {
225
247
  out.error('No thread IDs provided')
@@ -323,6 +345,24 @@ export function registerMailCommands(cli: Goke) {
323
345
  }
324
346
  console.log(pc.dim(`Date: ${dateStr}`))
325
347
 
348
+ if (msg.auth) {
349
+ const check = (verdict: string) => {
350
+ return verdict === 'pass'
351
+ ? pc.green('✓')
352
+ : pc.red('✗')
353
+ }
354
+ const parts = [
355
+ `${check(msg.auth.spf)} SPF`,
356
+ `${check(msg.auth.dkim)} DKIM`,
357
+ `${check(msg.auth.dmarc)} DMARC`,
358
+ ]
359
+ const label = msg.auth.authentic ? pc.green('authentic') : pc.red('UNVERIFIED')
360
+ console.log(`Auth: ${parts.join(' ')} (${label})`)
361
+ if (options.verify) {
362
+ console.log(pc.dim(` Raw: ${msg.auth.raw}`))
363
+ }
364
+ }
365
+
326
366
  if (msg.attachments.length > 0) {
327
367
  const attList = msg.attachments.map((a) => {
328
368
  const size = a.size < 1024 ? `${a.size} B`
@@ -414,6 +454,7 @@ export function registerMailCommands(cli: Goke) {
414
454
  fromEmail: options.from,
415
455
  attachments,
416
456
  })
457
+ if (result instanceof Error) handleCommandError(result)
417
458
 
418
459
  out.printYaml(result)
419
460
  out.success(`Sent to ${options.to}`)
@@ -430,6 +471,7 @@ export function registerMailCommands(cli: Goke) {
430
471
  .option('--cc <cc>', z.string().describe('Additional CC recipients'))
431
472
  .option('--all', 'Reply all (include all original recipients)')
432
473
  .option('--from <from>', z.string().describe('Send-as alias email'))
474
+ .option('--draft', 'Save as draft instead of sending')
433
475
  .action(async (threadId, options) => {
434
476
  let body = options.body ?? ''
435
477
  if (options.bodyFile) {
@@ -455,6 +497,21 @@ export function registerMailCommands(cli: Goke) {
455
497
  ? options.cc.split(',').map((e: string) => ({ email: e.trim() })).filter((e: { email: string }) => e.email)
456
498
  : undefined
457
499
 
500
+ if (options.draft) {
501
+ const result = await client.createDraftReply({
502
+ threadId,
503
+ body,
504
+ replyAll: options.all,
505
+ cc,
506
+ fromEmail: options.from,
507
+ })
508
+ if (result instanceof Error) handleCommandError(result)
509
+
510
+ out.printYaml(result)
511
+ out.success('Reply draft created')
512
+ return
513
+ }
514
+
458
515
  const result = await client.replyToThread({
459
516
  threadId,
460
517
  body,
@@ -477,6 +534,7 @@ export function registerMailCommands(cli: Goke) {
477
534
  .option('--to <to>', z.string().describe('Forward recipient(s), comma-separated'))
478
535
  .option('--body <body>', z.string().describe('Optional message to prepend'))
479
536
  .option('--from <from>', z.string().describe('Send-as alias email'))
537
+ .option('--draft', 'Save as draft instead of sending')
480
538
  .action(async (threadId, options) => {
481
539
  if (!options.to) {
482
540
  out.error('--to is required')
@@ -490,6 +548,20 @@ export function registerMailCommands(cli: Goke) {
490
548
 
491
549
  const { client } = await getClient(options.account)
492
550
 
551
+ if (options.draft) {
552
+ const result = await client.createDraftForward({
553
+ threadId,
554
+ to: recipients,
555
+ body: options.body,
556
+ fromEmail: options.from,
557
+ })
558
+ if (result instanceof Error) handleCommandError(result)
559
+
560
+ out.printYaml(result)
561
+ out.success('Forward draft created')
562
+ return
563
+ }
564
+
493
565
  const result = await client.forwardThread({
494
566
  threadId,
495
567
  to: recipients,
@@ -3,26 +3,32 @@
3
3
  // Cache is handled by the client — commands just call methods and use data.
4
4
  // Multi-account: shows all accounts or filtered by --account.
5
5
 
6
- import type { Goke } from 'goke'
6
+ import type { ZeleCli } from '../cli-types.js'
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
- export function registerProfileCommands(cli: Goke) {
12
+ export function registerProfileCommands(cli: ZeleCli) {
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
  }
@@ -2,7 +2,7 @@
2
2
  // Thin CLI wrapper around GmailClient.watchInbox() async generator.
3
3
  // Multi-account: watches all accounts concurrently and merges output.
4
4
 
5
- import type { Goke } from 'goke'
5
+ import type { ZeleCli } from '../cli-types.js'
6
6
  import { z } from 'zod'
7
7
  import { getClients } from '../auth.js'
8
8
  import type { WatchEvent } from '../gmail-client.js'
@@ -13,7 +13,7 @@ import * as out from '../output.js'
13
13
  // Register commands
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- export function registerWatchCommands(cli: Goke) {
16
+ export function registerWatchCommands(cli: ZeleCli) {
17
17
  cli
18
18
  .command('mail watch', 'Watch for new emails (poll via History API)')
19
19
  .option('--interval [interval]', z.string().describe('Poll interval in seconds (default: 15)'))