zele 0.3.17 → 0.3.21
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.
- package/README.md +81 -12
- package/dist/api-utils.d.ts +10 -0
- package/dist/api-utils.js +14 -0
- package/dist/api-utils.js.map +1 -1
- package/dist/cli-types.d.ts +4 -0
- package/dist/cli-types.js +6 -0
- package/dist/cli-types.js.map +1 -0
- package/dist/cli.js +1 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.d.ts +2 -2
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.d.ts +2 -2
- package/dist/commands/auth-cmd.js +58 -52
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.d.ts +2 -2
- package/dist/commands/calendar.js +13 -14
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.d.ts +2 -2
- package/dist/commands/draft.js +62 -15
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/filter.d.ts +2 -2
- package/dist/commands/filter.js.map +1 -1
- package/dist/commands/label.d.ts +2 -2
- package/dist/commands/label.js +5 -6
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.d.ts +2 -2
- package/dist/commands/mail-actions.js +290 -1
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.d.ts +2 -2
- package/dist/commands/mail.js +50 -10
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.d.ts +2 -2
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -2
- package/dist/commands/watch.js +2 -2
- package/dist/commands/watch.js.map +1 -1
- package/dist/gmail-client.d.ts +59 -3
- package/dist/gmail-client.js +119 -5
- package/dist/gmail-client.js.map +1 -1
- package/dist/imap-smtp-client.d.ts +75 -4
- package/dist/imap-smtp-client.js +131 -7
- package/dist/imap-smtp-client.js.map +1 -1
- package/dist/unsubscribe.d.ts +76 -0
- package/dist/unsubscribe.js +224 -0
- package/dist/unsubscribe.js.map +1 -0
- package/package.json +3 -2
- package/skills/zele/SKILL.md +32 -124
- package/src/api-utils.ts +14 -0
- package/src/cli-types.ts +8 -0
- package/src/cli.ts +2 -7
- package/src/commands/attachment.ts +2 -2
- package/src/commands/auth-cmd.ts +66 -56
- package/src/commands/calendar.ts +15 -16
- package/src/commands/draft.ts +71 -17
- package/src/commands/filter.ts +2 -2
- package/src/commands/label.ts +7 -8
- package/src/commands/mail-actions.ts +315 -4
- package/src/commands/mail.ts +54 -12
- package/src/commands/profile.ts +2 -2
- package/src/commands/watch.ts +4 -4
- package/src/gmail-client.ts +193 -6
- package/src/imap-smtp-client.ts +186 -7
- package/src/unsubscribe.test.ts +487 -0
- package/src/unsubscribe.ts +255 -0
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
// Mail action commands: star, unstar, archive, trash, untrash, mark read/unread,
|
|
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 {
|
|
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'
|
|
8
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'
|
|
9
17
|
import * as out from '../output.js'
|
|
10
18
|
import { handleCommandError } from '../output.js'
|
|
11
19
|
|
|
@@ -35,7 +43,7 @@ async function bulkAction(
|
|
|
35
43
|
// Register commands
|
|
36
44
|
// ---------------------------------------------------------------------------
|
|
37
45
|
|
|
38
|
-
export function registerMailActionCommands(cli:
|
|
46
|
+
export function registerMailActionCommands(cli: ZeleCli) {
|
|
39
47
|
cli
|
|
40
48
|
.command('mail star [...threadIds]', 'Star threads')
|
|
41
49
|
.action(async (threadIds, options) => {
|
|
@@ -121,4 +129,307 @@ export function registerMailActionCommands(cli: Goke) {
|
|
|
121
129
|
out.printYaml(result)
|
|
122
130
|
out.success(`Trashed ${result.count} spam thread(s)`)
|
|
123
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
|
+
}
|
|
124
435
|
}
|
package/src/commands/mail.ts
CHANGED
|
@@ -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 {
|
|
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'
|
|
@@ -13,6 +13,7 @@ import { getClients, getClient, listAccounts, login } from '../auth.js'
|
|
|
13
13
|
import type { ThreadListResult } from '../gmail-client.js'
|
|
14
14
|
import type { GmailClient } from '../gmail-client.js'
|
|
15
15
|
import { AuthError } from '../api-utils.js'
|
|
16
|
+
import { hasUnsubscribeMechanism, hasOneClickUnsubscribe } from '../unsubscribe.js'
|
|
16
17
|
import * as out from '../output.js'
|
|
17
18
|
import { handleCommandError } from '../output.js'
|
|
18
19
|
import pc from 'picocolors'
|
|
@@ -38,7 +39,7 @@ function formatLabels(labelIds: string[], labelMap?: Map<string, string>): strin
|
|
|
38
39
|
// Register commands
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
|
|
41
|
-
export function registerMailCommands(cli:
|
|
42
|
+
export function registerMailCommands(cli: ZeleCli) {
|
|
42
43
|
// =========================================================================
|
|
43
44
|
// mail (TUI)
|
|
44
45
|
// =========================================================================
|
|
@@ -51,13 +52,15 @@ export function registerMailCommands(cli: Goke) {
|
|
|
51
52
|
cli
|
|
52
53
|
.command('mail list', 'List email threads')
|
|
53
54
|
.option('--folder [folder]', 'Folder to list (inbox, sent, trash, spam, starred, drafts, archive, all) (default: inbox)')
|
|
54
|
-
.option('--
|
|
55
|
+
.option('--limit [limit]', 'Max threads to show (default: 20)')
|
|
55
56
|
.option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
|
|
56
57
|
.option('--label <label>', 'Filter by label name')
|
|
57
58
|
.option('--filter <filter>', 'Gmail search filter (e.g. "is:unread", "from:github", "has:attachment")')
|
|
58
59
|
.action(async (options) => {
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
// `options.folder` / `options.limit` are `string | undefined` now.
|
|
61
|
+
// `''` (bare flag) falls back to the default via `||`.
|
|
62
|
+
const folder = options.folder || 'inbox'
|
|
63
|
+
const limit = options.limit ? Number(options.limit) : 20
|
|
61
64
|
const clients = await getClients(options.account)
|
|
62
65
|
|
|
63
66
|
if (options.page && clients.length > 1) {
|
|
@@ -70,7 +73,7 @@ export function registerMailCommands(cli: Goke) {
|
|
|
70
73
|
clients.map(async ({ email, client, accountType }) => {
|
|
71
74
|
const result = await client.listThreads({
|
|
72
75
|
folder,
|
|
73
|
-
maxResults:
|
|
76
|
+
maxResults: limit,
|
|
74
77
|
labelIds: options.label ? [options.label] : undefined,
|
|
75
78
|
pageToken: options.page,
|
|
76
79
|
query: options.filter,
|
|
@@ -99,13 +102,13 @@ export function registerMailCommands(cli: Goke) {
|
|
|
99
102
|
const labelMap = new Map<string, string>()
|
|
100
103
|
for (const r of allResults) for (const [id, name] of r.labelMap) labelMap.set(id, name)
|
|
101
104
|
|
|
102
|
-
// Merge threads from all accounts, sorted by date descending, capped at
|
|
105
|
+
// Merge threads from all accounts, sorted by date descending, capped at limit
|
|
103
106
|
const merged = allResults
|
|
104
107
|
.flatMap(({ email, result }) =>
|
|
105
108
|
result.threads.map((t) => ({ ...t, account: email })),
|
|
106
109
|
)
|
|
107
110
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
108
|
-
.slice(0,
|
|
111
|
+
.slice(0, limit)
|
|
109
112
|
|
|
110
113
|
if (merged.length === 0) {
|
|
111
114
|
out.printList([], { summary: 'No threads found' })
|
|
@@ -118,6 +121,8 @@ export function registerMailCommands(cli: Goke) {
|
|
|
118
121
|
const to = t.to.map((s) => out.formatSender(s)).join(', ')
|
|
119
122
|
const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
|
|
120
123
|
const labels = formatLabels(t.labelIds, labelMap)
|
|
124
|
+
const canUnsubscribe = hasUnsubscribeMechanism(t.listUnsubscribe)
|
|
125
|
+
const oneClick = hasOneClickUnsubscribe(t.listUnsubscribe, t.listUnsubscribePost)
|
|
121
126
|
return {
|
|
122
127
|
...(showAccount ? { account: t.account } : {}),
|
|
123
128
|
id: t.id,
|
|
@@ -130,6 +135,8 @@ export function registerMailCommands(cli: Goke) {
|
|
|
130
135
|
date: out.formatDate(t.date),
|
|
131
136
|
messages: t.messageCount,
|
|
132
137
|
...(labels ? { labels } : {}),
|
|
138
|
+
...(canUnsubscribe ? { can_unsubscribe: true } : {}),
|
|
139
|
+
...(oneClick ? { one_click: true } : {}),
|
|
133
140
|
...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
|
|
134
141
|
}
|
|
135
142
|
}),
|
|
@@ -143,10 +150,10 @@ export function registerMailCommands(cli: Goke) {
|
|
|
143
150
|
|
|
144
151
|
cli
|
|
145
152
|
.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')
|
|
146
|
-
.option('--
|
|
153
|
+
.option('--limit [limit]', 'Max results to show (default: 20)')
|
|
147
154
|
.option('--page <page>', 'Pagination token (requires --account, only works for a single account)')
|
|
148
155
|
.action(async (query, options) => {
|
|
149
|
-
const
|
|
156
|
+
const limit = options.limit ? Number(options.limit) : 20
|
|
150
157
|
const clients = await getClients(options.account)
|
|
151
158
|
|
|
152
159
|
if (options.page && clients.length > 1) {
|
|
@@ -159,7 +166,7 @@ export function registerMailCommands(cli: Goke) {
|
|
|
159
166
|
clients.map(async ({ email, client, accountType }) => {
|
|
160
167
|
const result = await client.listThreads({
|
|
161
168
|
query,
|
|
162
|
-
maxResults:
|
|
169
|
+
maxResults: limit,
|
|
163
170
|
pageToken: options.page,
|
|
164
171
|
})
|
|
165
172
|
if (result instanceof Error) return result
|
|
@@ -190,7 +197,7 @@ export function registerMailCommands(cli: Goke) {
|
|
|
190
197
|
result.threads.map((t) => ({ ...t, account: email })),
|
|
191
198
|
)
|
|
192
199
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
193
|
-
.slice(0,
|
|
200
|
+
.slice(0, limit)
|
|
194
201
|
|
|
195
202
|
if (merged.length === 0) {
|
|
196
203
|
out.printList([], { summary: `No results for "${query}"` })
|
|
@@ -203,6 +210,8 @@ export function registerMailCommands(cli: Goke) {
|
|
|
203
210
|
const to = t.to.map((s) => out.formatSender(s)).join(', ')
|
|
204
211
|
const cc = t.cc.map((s) => out.formatSender(s)).join(', ')
|
|
205
212
|
const labels = formatLabels(t.labelIds, labelMap)
|
|
213
|
+
const canUnsubscribe = hasUnsubscribeMechanism(t.listUnsubscribe)
|
|
214
|
+
const oneClick = hasOneClickUnsubscribe(t.listUnsubscribe, t.listUnsubscribePost)
|
|
206
215
|
return {
|
|
207
216
|
...(showAccount ? { account: t.account } : {}),
|
|
208
217
|
id: t.id,
|
|
@@ -215,6 +224,8 @@ export function registerMailCommands(cli: Goke) {
|
|
|
215
224
|
date: out.formatDate(t.date),
|
|
216
225
|
messages: t.messageCount,
|
|
217
226
|
...(labels ? { labels } : {}),
|
|
227
|
+
...(canUnsubscribe ? { can_unsubscribe: true } : {}),
|
|
228
|
+
...(oneClick ? { one_click: true } : {}),
|
|
218
229
|
...(t.listUnsubscribe ? { unsubscribe: t.listUnsubscribe } : {}),
|
|
219
230
|
}
|
|
220
231
|
}),
|
|
@@ -460,6 +471,7 @@ export function registerMailCommands(cli: Goke) {
|
|
|
460
471
|
.option('--cc <cc>', z.string().describe('Additional CC recipients'))
|
|
461
472
|
.option('--all', 'Reply all (include all original recipients)')
|
|
462
473
|
.option('--from <from>', z.string().describe('Send-as alias email'))
|
|
474
|
+
.option('--draft', 'Save as draft instead of sending')
|
|
463
475
|
.action(async (threadId, options) => {
|
|
464
476
|
let body = options.body ?? ''
|
|
465
477
|
if (options.bodyFile) {
|
|
@@ -485,6 +497,21 @@ export function registerMailCommands(cli: Goke) {
|
|
|
485
497
|
? options.cc.split(',').map((e: string) => ({ email: e.trim() })).filter((e: { email: string }) => e.email)
|
|
486
498
|
: undefined
|
|
487
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
|
+
|
|
488
515
|
const result = await client.replyToThread({
|
|
489
516
|
threadId,
|
|
490
517
|
body,
|
|
@@ -507,6 +534,7 @@ export function registerMailCommands(cli: Goke) {
|
|
|
507
534
|
.option('--to <to>', z.string().describe('Forward recipient(s), comma-separated'))
|
|
508
535
|
.option('--body <body>', z.string().describe('Optional message to prepend'))
|
|
509
536
|
.option('--from <from>', z.string().describe('Send-as alias email'))
|
|
537
|
+
.option('--draft', 'Save as draft instead of sending')
|
|
510
538
|
.action(async (threadId, options) => {
|
|
511
539
|
if (!options.to) {
|
|
512
540
|
out.error('--to is required')
|
|
@@ -520,6 +548,20 @@ export function registerMailCommands(cli: Goke) {
|
|
|
520
548
|
|
|
521
549
|
const { client } = await getClient(options.account)
|
|
522
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
|
+
|
|
523
565
|
const result = await client.forwardThread({
|
|
524
566
|
threadId,
|
|
525
567
|
to: recipients,
|
package/src/commands/profile.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
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 {
|
|
6
|
+
import type { ZeleCli } from '../cli-types.js'
|
|
7
7
|
import { getClients } from '../auth.js'
|
|
8
8
|
import type { GmailClient } from '../gmail-client.js'
|
|
9
9
|
import { AuthError } from '../api-utils.js'
|
|
10
10
|
import * as out from '../output.js'
|
|
11
11
|
|
|
12
|
-
export function registerProfileCommands(cli:
|
|
12
|
+
export function registerProfileCommands(cli: ZeleCli) {
|
|
13
13
|
cli
|
|
14
14
|
.command('profile', 'Show account info')
|
|
15
15
|
.action(async (options) => {
|
package/src/commands/watch.ts
CHANGED
|
@@ -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 {
|
|
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,12 +13,12 @@ import * as out from '../output.js'
|
|
|
13
13
|
// Register commands
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
export function registerWatchCommands(cli:
|
|
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)'))
|
|
20
20
|
.option('--folder [folder]', z.string().describe('Folder to watch (default: inbox)'))
|
|
21
|
-
.option('--
|
|
21
|
+
.option('--filter [filter]', z.string().describe('Filter messages client-side (from:, to:, cc:, subject:, is:unread, is:starred, has:attachment, -negate). See https://support.google.com/mail/answer/7190'))
|
|
22
22
|
.option('--once', z.boolean().describe('Print changes once and exit (no loop)'))
|
|
23
23
|
.action(async (options) => {
|
|
24
24
|
const interval = options.interval ? Number(options.interval) : 15
|
|
@@ -43,7 +43,7 @@ export function registerWatchCommands(cli: Goke) {
|
|
|
43
43
|
client.watchInbox({
|
|
44
44
|
folder,
|
|
45
45
|
intervalMs: interval * 1000,
|
|
46
|
-
query: options.
|
|
46
|
+
query: options.filter,
|
|
47
47
|
once: options.once,
|
|
48
48
|
}),
|
|
49
49
|
)
|