zele 0.3.17 → 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 (60) hide show
  1. package/README.md +64 -0
  2. package/dist/api-utils.d.ts +10 -0
  3. package/dist/api-utils.js +14 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/cli-types.d.ts +4 -0
  6. package/dist/cli-types.js +6 -0
  7. package/dist/cli-types.js.map +1 -0
  8. package/dist/cli.js +1 -5
  9. package/dist/cli.js.map +1 -1
  10. package/dist/commands/attachment.d.ts +2 -2
  11. package/dist/commands/attachment.js.map +1 -1
  12. package/dist/commands/auth-cmd.d.ts +2 -2
  13. package/dist/commands/auth-cmd.js.map +1 -1
  14. package/dist/commands/calendar.d.ts +2 -2
  15. package/dist/commands/calendar.js.map +1 -1
  16. package/dist/commands/draft.d.ts +2 -2
  17. package/dist/commands/draft.js +51 -3
  18. package/dist/commands/draft.js.map +1 -1
  19. package/dist/commands/filter.d.ts +2 -2
  20. package/dist/commands/filter.js.map +1 -1
  21. package/dist/commands/label.d.ts +2 -2
  22. package/dist/commands/label.js.map +1 -1
  23. package/dist/commands/mail-actions.d.ts +2 -2
  24. package/dist/commands/mail-actions.js +290 -1
  25. package/dist/commands/mail-actions.js.map +1 -1
  26. package/dist/commands/mail.d.ts +2 -2
  27. package/dist/commands/mail.js +41 -1
  28. package/dist/commands/mail.js.map +1 -1
  29. package/dist/commands/profile.d.ts +2 -2
  30. package/dist/commands/profile.js.map +1 -1
  31. package/dist/commands/watch.d.ts +2 -2
  32. package/dist/commands/watch.js.map +1 -1
  33. package/dist/gmail-client.d.ts +59 -3
  34. package/dist/gmail-client.js +119 -5
  35. package/dist/gmail-client.js.map +1 -1
  36. package/dist/imap-smtp-client.d.ts +75 -4
  37. package/dist/imap-smtp-client.js +131 -7
  38. package/dist/imap-smtp-client.js.map +1 -1
  39. package/dist/unsubscribe.d.ts +76 -0
  40. package/dist/unsubscribe.js +224 -0
  41. package/dist/unsubscribe.js.map +1 -0
  42. package/package.json +2 -2
  43. package/skills/zele/SKILL.md +26 -125
  44. package/src/api-utils.ts +14 -0
  45. package/src/cli-types.ts +8 -0
  46. package/src/cli.ts +2 -7
  47. package/src/commands/attachment.ts +2 -2
  48. package/src/commands/auth-cmd.ts +2 -2
  49. package/src/commands/calendar.ts +2 -2
  50. package/src/commands/draft.ts +60 -5
  51. package/src/commands/filter.ts +2 -2
  52. package/src/commands/label.ts +2 -2
  53. package/src/commands/mail-actions.ts +315 -4
  54. package/src/commands/mail.ts +45 -3
  55. package/src/commands/profile.ts +2 -2
  56. package/src/commands/watch.ts +2 -2
  57. package/src/gmail-client.ts +193 -6
  58. package/src/imap-smtp-client.ts +186 -7
  59. package/src/unsubscribe.test.ts +487 -0
  60. package/src/unsubscribe.ts +255 -0
@@ -0,0 +1,224 @@
1
+ // Unsubscribe header parsing and mechanism planning.
2
+ // Implements RFC 2369 (List-Unsubscribe) + RFC 8058 (List-Unsubscribe-Post
3
+ // One-Click). Pure functions only — no network, no client access.
4
+ //
5
+ // RFC 2369 says List-Unsubscribe contains one or more angle-bracket-enclosed
6
+ // URIs, comma-separated. Each URI is either mailto: (send an email) or
7
+ // http(s): (a landing page).
8
+ //
9
+ // RFC 8058 adds one-click: when both List-Unsubscribe and
10
+ // List-Unsubscribe-Post (with the single value "List-Unsubscribe=One-Click")
11
+ // are present, a client can POST `List-Unsubscribe=One-Click` to the https
12
+ // URL with no cookies, no auth, and no redirects allowed.
13
+ // ---------------------------------------------------------------------------
14
+ // Parsing
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * Split a List-Unsubscribe header into its `<URI>` entries. Commas only
18
+ * separate entries when they are outside angle brackets; whitespace and
19
+ * line folding inside the header are tolerated per RFC 2369 §2.
20
+ */
21
+ export function parseListUnsubscribeEntries(header) {
22
+ if (!header)
23
+ return [];
24
+ const entries = [];
25
+ let depth = 0;
26
+ let current = '';
27
+ for (const ch of header) {
28
+ if (ch === '<') {
29
+ depth++;
30
+ current += ch;
31
+ continue;
32
+ }
33
+ if (ch === '>') {
34
+ depth = Math.max(0, depth - 1);
35
+ current += ch;
36
+ continue;
37
+ }
38
+ if (ch === ',' && depth === 0) {
39
+ entries.push(current);
40
+ current = '';
41
+ continue;
42
+ }
43
+ current += ch;
44
+ }
45
+ if (current.trim().length > 0)
46
+ entries.push(current);
47
+ // Extract the URI inside each angle bracket pair. Anything outside is ignored
48
+ // (comments, trailing text) per RFC 2369 §2 guideline 2.
49
+ const result = [];
50
+ for (const raw of entries) {
51
+ const match = raw.match(/<([^>]*)>/);
52
+ if (!match)
53
+ continue;
54
+ const uri = match[1].replace(/\s+/g, '').trim();
55
+ if (uri.length > 0)
56
+ result.push(uri);
57
+ }
58
+ return result;
59
+ }
60
+ /**
61
+ * Parse a mailto: URI into its target address and optional subject/body/cc.
62
+ * Returns null if the URI is not a valid mailto.
63
+ *
64
+ * Supports percent-encoding per RFC 6068. Note: `mailto:` is NOT
65
+ * application/x-www-form-urlencoded, so `+` is preserved literally (this
66
+ * matters for plus-addressing like `foo+tag@example.com`).
67
+ */
68
+ export function parseMailto(uri) {
69
+ if (!/^mailto:/i.test(uri))
70
+ return null;
71
+ const afterScheme = uri.slice('mailto:'.length);
72
+ const qIndex = afterScheme.indexOf('?');
73
+ const rawTo = qIndex === -1 ? afterScheme : afterScheme.slice(0, qIndex);
74
+ const query = qIndex === -1 ? '' : afterScheme.slice(qIndex + 1);
75
+ const decode = (s) => {
76
+ try {
77
+ // RFC 6068: only percent-decode. Do NOT rewrite `+` → space.
78
+ return decodeURIComponent(s);
79
+ }
80
+ catch {
81
+ return s;
82
+ }
83
+ };
84
+ // Strip CRLF (defense-in-depth against header injection through mailto
85
+ // fields that get passed to SMTP/mimetext). Only body is allowed to keep
86
+ // newlines because it becomes message content.
87
+ const sanitizeHeader = (s) => s.replace(/[\r\n]/g, '').trim();
88
+ const to = sanitizeHeader(decode(rawTo));
89
+ if (!to)
90
+ return null;
91
+ const spec = { to };
92
+ if (!query)
93
+ return spec;
94
+ const cc = [];
95
+ for (const pair of query.split('&')) {
96
+ if (!pair)
97
+ continue;
98
+ const eq = pair.indexOf('=');
99
+ const key = (eq === -1 ? pair : pair.slice(0, eq)).toLowerCase();
100
+ const value = eq === -1 ? '' : decode(pair.slice(eq + 1));
101
+ if (key === 'subject')
102
+ spec.subject = sanitizeHeader(value);
103
+ else if (key === 'body')
104
+ spec.body = value;
105
+ else if (key === 'cc') {
106
+ for (const addr of value.split(',')) {
107
+ const trimmed = sanitizeHeader(addr);
108
+ if (trimmed)
109
+ cc.push(trimmed);
110
+ }
111
+ }
112
+ }
113
+ if (cc.length > 0)
114
+ spec.cc = cc;
115
+ return spec;
116
+ }
117
+ /**
118
+ * Parse a List-Unsubscribe-Post header value. Per RFC 8058 §5 the only legal
119
+ * value is exactly `List-Unsubscribe=One-Click`. Whitespace-tolerant.
120
+ */
121
+ export function parseListUnsubscribePost(header) {
122
+ if (!header)
123
+ return false;
124
+ return header.replace(/\s+/g, '').toLowerCase() === 'list-unsubscribe=one-click';
125
+ }
126
+ /**
127
+ * Lightweight check for whether a message has any standardized unsubscribe
128
+ * mechanism advertised. Used by list views that want to surface a
129
+ * `can_unsubscribe` boolean without building a full plan. Returns true when
130
+ * List-Unsubscribe contains at least one valid mailto: or http(s): entry.
131
+ */
132
+ export function hasUnsubscribeMechanism(listUnsubscribe) {
133
+ if (!listUnsubscribe)
134
+ return false;
135
+ const entries = parseListUnsubscribeEntries(listUnsubscribe);
136
+ return entries.some((e) => /^(mailto|https?):/i.test(e));
137
+ }
138
+ /**
139
+ * Check whether a message advertises RFC 8058 one-click unsubscribe: both
140
+ * List-Unsubscribe-Post=One-Click and at least one https: URL in
141
+ * List-Unsubscribe. Does not check DKIM — the executor is responsible for
142
+ * that gating at the point of actually POSTing.
143
+ */
144
+ export function hasOneClickUnsubscribe(listUnsubscribe, listUnsubscribePost) {
145
+ if (!parseListUnsubscribePost(listUnsubscribePost ?? undefined))
146
+ return false;
147
+ if (!listUnsubscribe)
148
+ return false;
149
+ return parseListUnsubscribeEntries(listUnsubscribe).some((e) => /^https:/i.test(e));
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Planning
153
+ // ---------------------------------------------------------------------------
154
+ /**
155
+ * Build an unsubscribe plan from the raw headers + DKIM authenticity flag.
156
+ *
157
+ * Preference order:
158
+ * 1. RFC 8058 one-click (List-Unsubscribe-Post present + https URL).
159
+ * Emitted first because it is the spec-preferred programmatic path.
160
+ * 2. The remaining entries in sender-declared order (RFC 2369 §2 says
161
+ * clients should prefer left-to-right order). Each http(s): entry
162
+ * becomes a `url` mechanism, each mailto: entry becomes a `mailto`.
163
+ */
164
+ export function planUnsubscribe({ listUnsubscribe, listUnsubscribePost, dkimAuthentic, }) {
165
+ const warnings = [];
166
+ const mechanisms = [];
167
+ const entries = listUnsubscribe ? parseListUnsubscribeEntries(listUnsubscribe) : [];
168
+ const postOneClick = parseListUnsubscribePost(listUnsubscribePost);
169
+ // First pass: RFC 8058 one-click extraction. Any https entries become
170
+ // one-click candidates if the Post header is present.
171
+ const oneClickUrls = new Set();
172
+ let hasOneClick = false;
173
+ if (postOneClick) {
174
+ const httpsEntries = entries.filter((e) => /^https:/i.test(e));
175
+ if (httpsEntries.length > 0) {
176
+ hasOneClick = true;
177
+ for (const url of httpsEntries) {
178
+ mechanisms.push({ kind: 'one-click', url });
179
+ oneClickUrls.add(url);
180
+ }
181
+ }
182
+ else if (entries.some((e) => /^http:/i.test(e))) {
183
+ warnings.push('List-Unsubscribe-Post is present but no https URL (only http); RFC 8058 requires https');
184
+ }
185
+ else {
186
+ warnings.push('List-Unsubscribe-Post is present but no http(s) URL to POST to');
187
+ }
188
+ }
189
+ // Second pass: legacy fallbacks in sender-declared order (RFC 2369 §2
190
+ // left-to-right preference). http(s) URLs already claimed by one-click
191
+ // are skipped so we don't emit duplicate mechanisms.
192
+ for (const uri of entries) {
193
+ if (/^mailto:/i.test(uri)) {
194
+ const mailto = parseMailto(uri);
195
+ if (mailto)
196
+ mechanisms.push({ kind: 'mailto', mailto });
197
+ continue;
198
+ }
199
+ if (/^https?:/i.test(uri)) {
200
+ if (oneClickUrls.has(uri))
201
+ continue;
202
+ mechanisms.push({ kind: 'url', url: uri });
203
+ continue;
204
+ }
205
+ }
206
+ // DKIM safety notes for one-click. RFC 8058 §4 says the message SHOULD have
207
+ // a valid DKIM signature covering both headers; we approximate with a
208
+ // DKIM=pass verdict from the receiving MTA.
209
+ if (hasOneClick) {
210
+ if (dkimAuthentic === false) {
211
+ warnings.push('DKIM did not pass; one-click may be spoofed by an attacker');
212
+ }
213
+ else if (dkimAuthentic === null) {
214
+ warnings.push('DKIM status unknown (no authentication info on this message)');
215
+ }
216
+ }
217
+ return {
218
+ mechanisms,
219
+ hasOneClick,
220
+ dkimAuthentic,
221
+ warnings,
222
+ };
223
+ }
224
+ //# sourceMappingURL=unsubscribe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unsubscribe.js","sourceRoot":"","sources":["../src/unsubscribe.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zele",
3
- "version": "0.3.17",
3
+ "version": "0.3.20",
4
4
  "description": "Email & Calendar CLI — Gmail, IMAP/SMTP, Google Calendar from your terminal",
5
5
  "type": "module",
6
6
  "main": "dist/cli.js",
@@ -35,7 +35,7 @@
35
35
  "email-reply-parser": "^2.3.5",
36
36
  "errore": "^0.11.0",
37
37
  "fkill": "^10.0.3",
38
- "goke": "^6.1.3",
38
+ "goke": "^6.6.0",
39
39
  "google-auth-library": "^10.5.0",
40
40
  "imapflow": "^1.2.18",
41
41
  "js-yaml": "^4.1.1",
@@ -1,141 +1,42 @@
1
1
  ---
2
2
  name: zele
3
3
  description: >
4
- Control Gmail and Google Calendar via CLI. Read, search, send, reply, and forward
5
- emails. Create, update, and delete calendar events. Manage drafts, labels, and attachments.
6
- Supports multiple Google accounts and IMAP/SMTP accounts (Fastmail, Outlook, any provider).
7
- Use this skill whenever the user asks to check email, send messages, schedule meetings,
8
- or manage their calendar.
4
+ zele is a multi-account email and calendar CLI for Gmail, IMAP/SMTP
5
+ (Fastmail, Outlook, any provider), and Google Calendar. It reads,
6
+ searches, sends, replies, forwards, archives, stars, and trashes emails,
7
+ manages drafts, labels, attachments, and Gmail filters, and creates,
8
+ updates, and deletes calendar events with RSVP and free/busy support.
9
+ Output is YAML so commands can be piped through yq and xargs. ALWAYS
10
+ load this skill when the user asks to check email, read/send messages,
11
+ reply or forward, archive or trash threads, manage drafts or labels,
12
+ download attachments, schedule meetings, check their calendar, RSVP
13
+ to events, or when they run any `zele` command. Load it before writing
14
+ any code or shell commands that touch zele so you know the correct
15
+ subcommand structure, the Google vs IMAP feature matrix, the headless
16
+ login flow, and the agent-specific rules.
9
17
  ---
10
18
 
11
- # zele — Email & Calendar CLI
19
+ # zele
12
20
 
13
- A multi-account email and calendar client supporting **Google OAuth** and **IMAP/SMTP** (Fastmail, Outlook, any provider). Output is YAML, pipe-friendly.
14
-
15
- ## Setup
16
-
17
- ```bash
18
- # install (requires bun)
19
- bun install -g zele
20
-
21
- # show connected accounts
22
- zele whoami
23
-
24
- # authenticate with Google (opens browser, supports multiple accounts)
25
- zele login
26
-
27
- # authenticate with IMAP/SMTP (non-interactive, designed for agents)
28
- zele login imap \
29
- --email you@fastmail.com \
30
- --imap-host imap.fastmail.com --imap-port 993 \
31
- --smtp-host smtp.fastmail.com --smtp-port 465 \
32
- --password "your-app-password"
33
- ```
34
-
35
- **IMAP/SMTP login options:** `--imap-user` / `--smtp-user` if the login username differs from email. Omit `--smtp-host` for read-only (no sending). Use `--imap-password` / `--smtp-password` for separate credentials.
36
-
37
- **Remote/headless Google login:** `zele login` is interactive — it prints an authorization URL and waits for a redirect URL to be pasted back. In agent/headless environments, run it inside tmux so the process persists:
21
+ Every time you use zele, you MUST fetch the latest README:
38
22
 
39
23
  ```bash
40
- # start login in a tmux session
41
- tmux new-session -d -s zele-login 'zele login'
42
-
43
- # read the authorization URL from tmux output
44
- tmux capture-pane -t zele-login -p
45
-
46
- # after the user completes consent in their browser, paste the redirect URL
47
- tmux send-keys -t zele-login 'http://localhost:...?code=...' Enter
48
-
49
- # verify login succeeded
50
- tmux capture-pane -t zele-login -p
51
- tmux kill-session -t zele-login
24
+ curl -s https://raw.githubusercontent.com/remorses/zele/main/README.md # NEVER pipe to head/tail, read the full output
52
25
  ```
53
26
 
54
- ## Important
55
-
56
- **Always run `zele --help` before using.** The help output is the source of truth for all commands, options, and syntax. Run `zele <command> --help` for subcommand details (e.g. `zele mail send --help`). NEVER use head to truncate the output. read it fully.
57
-
58
- Running `zele` with no subcommand launches a human-friendly TUI for browsing email. **Agents should not use the TUI** — always use the CLI subcommands (`zele mail list`, `zele cal events`, etc.) which output structured YAML.
59
-
60
- ## Capabilities
61
-
62
- - **Mail:** list, search, read, send, reply, forward, star, archive, trash, watch for new emails (Google + IMAP)
63
- - **Drafts:** list, create, get, send, delete (Google + IMAP)
64
- - **Attachments:** list per thread, download (Google + IMAP)
65
- - **Labels:** list, create, delete, unread counts (Google only)
66
- - **Filters:** list server-side filters (Google only)
67
- - **Calendar:** list calendars, list/search events, create/update/delete events, RSVP, free/busy (Google only)
68
- - **Multi-account:** all commands support `--account <email>` to filter; list/search merge across all account types
69
-
70
- ## Account discovery
71
-
72
- When the user asks to check emails **for a specific account** (e.g. "check my work email", "what's new on my personal Gmail?"), always run `zele whoami` first to list the connected accounts and find the exact email address to pass to `--account`. Never guess the email — use the output of `zele whoami` to pick the right one. The output also shows account type (`google` or `imap_smtp`) and capabilities.
27
+ Then run the CLI help once — it already includes every subcommand, option, and flag:
73
28
 
74
29
  ```bash
75
- # list connected accounts
76
- zele whoami
77
-
78
- # then use the email from the output
79
- zele mail list --account user@work.com
30
+ zele --help # NEVER pipe to head/tail, read the full output
80
31
  ```
81
32
 
82
- ## Google-only features
83
-
84
- These commands only work with Google accounts. IMAP/SMTP accounts show a helpful error:
33
+ The README and `zele --help` output are the source of truth for commands, options, flags, the Google vs IMAP feature matrix, search operators, and the headless login flow.
85
34
 
86
- - `zele label list/counts/create/delete` — IMAP uses folders, not labels
87
- - `zele mail label` — adding/removing labels
88
- - `zele mail filter list` — server-side Gmail filters
89
- - `zele cal *` — calendar requires Google OAuth
90
- - `zele profile` — shows limited info for IMAP (email only, no message counts)
35
+ ## Rules
91
36
 
92
- ## IMAP search support
93
-
94
- IMAP accounts support a subset of Gmail query syntax, translated to IMAP SEARCH:
95
-
96
- `from:`, `to:`, `subject:`, `is:unread`, `is:starred`, `has:attachment`, `newer_than:Nd`, `older_than:Nm`, `after:YYYY/MM/DD`, `before:YYYY/MM/DD`
97
-
98
- Unsupported on IMAP: `cc:`, `-` (negate), `label:`, `in:`, `filename:`, `size:`/`larger:`/`smaller:`, `OR`, `{ }`.
99
-
100
- ## Examples
101
-
102
- ```bash
103
- # list inbox
104
- zele mail list
105
-
106
- # list only unread emails
107
- zele mail list --filter "is:unread"
108
-
109
- # list emails from last 7 days (works for both Google and IMAP)
110
- zele mail list --filter "newer_than:7d"
111
-
112
- # combine filter with folder
113
- zele mail list --filter "from:github" --folder sent
114
-
115
- # search mail
116
- zele mail search "from:github subject:review"
117
-
118
- # read a thread (thread IDs come from list/search output)
119
- zele mail read <threadId>
120
-
121
- # send an email with attachment
122
- zele mail send --to alice@example.com --subject "Report" --body "See attached" --attach report.pdf
123
-
124
- # reply all
125
- zele mail reply <threadId> --body "Thanks!" --all
126
-
127
- # watch inbox for new mail (polls every 15s)
128
- zele mail watch
129
-
130
- # add an IMAP account (non-interactive, for agents)
131
- zele login imap --email you@fastmail.com --imap-host imap.fastmail.com --smtp-host smtp.fastmail.com --password "app-pass"
132
-
133
- # today's calendar events (Google only)
134
- zele cal events --today --all
135
-
136
- # create a meeting with Google Meet (Google only)
137
- zele cal create --summary "Standup" --from tomorrow --to +30m --meet --attendees bob@example.com
138
-
139
- # list Gmail filters (Google only)
140
- zele mail filter list
141
- ```
37
+ 1. **Never use the TUI.** Running `zele` with no subcommand launches a human-facing TUI. Agents must use the CLI subcommands (`zele mail list`, `zele cal events`, etc.) which output structured YAML.
38
+ 2. **Always run `zele whoami` first** when the user asks to operate on a specific account. Pick the exact email from the output and pass it with `--account`. Never guess account emails.
39
+ 3. **Never truncate `--help` or README output** with `head`, `tail`, `sed`, `awk`, or `less`. Critical rules are spread throughout. Read them in full.
40
+ 4. **Parse YAML output with `yq`**, not regex. Pipe IDs through `xargs` for bulk actions: `zele mail list --filter "is:unread" | yq '.[].id' | xargs zele mail archive`.
41
+ 5. **Google-only features** (labels, Gmail filters, `zele cal *`, full profile) fail on IMAP accounts with a clear error. Check `zele whoami` output for account type before using them.
42
+ 6. **Headless Google login** requires a tmux wrapper because `zele login` is interactive. See the README "Remote / headless login" section for the exact pattern.
package/src/api-utils.ts CHANGED
@@ -126,6 +126,20 @@ export class UnsupportedError extends errore.createTaggedError({
126
126
  message: '$feature is not available for $accountType accounts. $hint',
127
127
  }) {}
128
128
 
129
+ /** Returned when a thread has no List-Unsubscribe header (RFC 2369) so no
130
+ * standardized unsubscribe mechanism is available. */
131
+ export class UnsubscribeUnavailableError extends errore.createTaggedError({
132
+ name: 'UnsubscribeUnavailableError',
133
+ message: 'No List-Unsubscribe header on thread $threadId',
134
+ }) {}
135
+
136
+ /** Returned when the unsubscribe attempt itself failed (HTTP error, SMTP
137
+ * send failure, redirect returned when RFC 8058 forbids it, etc.). */
138
+ export class UnsubscribeFailedError extends errore.createTaggedError({
139
+ name: 'UnsubscribeFailedError',
140
+ message: 'Unsubscribe via $mechanism failed: $reason',
141
+ }) {}
142
+
129
143
  /** Detect auth-like errors from underlying libraries (tsdav string errors, googleapis structured errors).
130
144
  * Used inside clients to decide whether to return an AuthError.
131
145
  * NOTE: String matching here is intentional — this is the boundary layer that converts
@@ -0,0 +1,8 @@
1
+ // Shared goke type for the zele CLI, including the global options declared
2
+ // on the root `goke('zele')` instance. Living in its own file avoids the
3
+ // circular import that would arise from command modules importing from
4
+ // `./cli.js` (which itself imports every command module).
5
+
6
+ import type { Goke } from 'goke'
7
+
8
+ export type ZeleCli = Goke<{ account?: string[] }>
package/src/cli.ts CHANGED
@@ -9,6 +9,7 @@ import { goke } from 'goke'
9
9
  import { z } from 'zod'
10
10
  import React from 'react'
11
11
  import { listAccounts, login } from './auth.js'
12
+ import type { ZeleCli } from './cli-types.js'
12
13
  import { registerAuthCommands } from './commands/auth-cmd.js'
13
14
  import { registerMailCommands } from './commands/mail.js'
14
15
  import { registerMailActionCommands } from './commands/mail-actions.js'
@@ -21,13 +22,7 @@ import { registerWatchCommands } from './commands/watch.js'
21
22
  import { registerFilterCommands } from './commands/filter.js'
22
23
  import { handleCommandError } from './output.js'
23
24
 
24
- const cli = goke('zele')
25
-
26
- // ---------------------------------------------------------------------------
27
- // Global options
28
- // ---------------------------------------------------------------------------
29
-
30
- cli.option(
25
+ const cli: ZeleCli = goke('zele').option(
31
26
  '--account <account>',
32
27
  z.array(z.string()).describe('Filter by email account (repeatable)'),
33
28
  )
@@ -2,7 +2,7 @@
2
2
  // Lists attachments for a thread and downloads them to disk.
3
3
  // Skips re-download if file already exists with same size (like gogcli).
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 fs from 'node:fs'
8
8
  import path from 'node:path'
@@ -10,7 +10,7 @@ import { getClient } from '../auth.js'
10
10
  import * as out from '../output.js'
11
11
  import { handleCommandError } from '../output.js'
12
12
 
13
- export function registerAttachmentCommands(cli: Goke) {
13
+ export function registerAttachmentCommands(cli: ZeleCli) {
14
14
  // =========================================================================
15
15
  // attachment list
16
16
  // =========================================================================
@@ -2,7 +2,7 @@
2
2
  // Manages authentication for zele (Google OAuth and IMAP/SMTP credentials).
3
3
  // Supports multiple accounts: login adds accounts, logout removes one.
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 pc from 'picocolors'
8
8
  import { login, loginImap, logout, listAccounts, getAuthStatuses } from '../auth.js'
@@ -10,7 +10,7 @@ import { closePrisma } from '../db.js'
10
10
  import * as out from '../output.js'
11
11
  import { handleCommandError } from '../output.js'
12
12
 
13
- export function registerAuthCommands(cli: Goke) {
13
+ export function registerAuthCommands(cli: ZeleCli) {
14
14
  cli
15
15
  .command('login', 'Authenticate with Google (opens browser) or show IMAP/SMTP login instructions')
16
16
  .action(async () => {
@@ -3,7 +3,7 @@
3
3
  // Cache is handled by the client — commands just call methods and use data.
4
4
  // Multi-account: list/events fetch all accounts concurrently and merge by start time.
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 readline from 'node:readline'
9
9
  import { getCalendarClients, getCalendarClient } from '../auth.js'
@@ -17,7 +17,7 @@ import { resolveTimeRange, parseTimeExpression, parseDuration, isDateOnly } from
17
17
  // Register commands
18
18
  // ---------------------------------------------------------------------------
19
19
 
20
- export function registerCalendarCommands(cli: Goke) {
20
+ export function registerCalendarCommands(cli: ZeleCli) {
21
21
  // =========================================================================
22
22
  // cal list
23
23
  // =========================================================================
@@ -3,7 +3,7 @@
3
3
  // Cache invalidation is handled by the client (sendDraft invalidates threadLists).
4
4
  // Multi-account: list fetches all accounts concurrently and merges 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 { getClients, getClient } from '../auth.js'
@@ -14,7 +14,7 @@ import * as out from '../output.js'
14
14
  import { handleCommandError } from '../output.js'
15
15
  import pc from 'picocolors'
16
16
 
17
- export function registerDraftCommands(cli: Goke) {
17
+ export function registerDraftCommands(cli: ZeleCli) {
18
18
  // =========================================================================
19
19
  // draft list
20
20
  // =========================================================================
@@ -89,14 +89,17 @@ export function registerDraftCommands(cli: Goke) {
89
89
  const draft = await client.getDraft({ draftId })
90
90
  if (draft instanceof Error) handleCommandError(draft)
91
91
 
92
+ const fmtRecipients = (list: Array<{ name?: string; email: string }>) =>
93
+ list.map((r) => r.name && r.name !== r.email ? `${r.name} <${r.email}>` : r.email).join(', ')
94
+
92
95
  console.log(pc.bold(`Draft: ${draft.message.subject}`))
93
96
  console.log(pc.dim(`Draft ID: ${draft.id}`))
94
- console.log(`To: ${draft.to.join(', ') || '(none)'}`)
97
+ console.log(`To: ${fmtRecipients(draft.to) || '(none)'}`)
95
98
  if (draft.cc.length > 0) {
96
- console.log(`Cc: ${draft.cc.join(', ')}`)
99
+ console.log(`Cc: ${fmtRecipients(draft.cc)}`)
97
100
  }
98
101
  if (draft.bcc.length > 0) {
99
- console.log(`Bcc: ${draft.bcc.join(', ')}`)
102
+ console.log(`Bcc: ${fmtRecipients(draft.bcc)}`)
100
103
  }
101
104
  console.log()
102
105
 
@@ -161,6 +164,58 @@ export function registerDraftCommands(cli: Goke) {
161
164
  out.success('Draft created')
162
165
  })
163
166
 
167
+ // =========================================================================
168
+ // draft update
169
+ // =========================================================================
170
+
171
+ cli
172
+ .command('draft update <draftId>', 'Update an existing draft')
173
+ .option('--to <to>', z.string().describe('New recipient email(s), comma-separated'))
174
+ .option('--subject <subject>', z.string().describe('New subject'))
175
+ .option('--body <body>', z.string().describe('New body text'))
176
+ .option('--body-file <bodyFile>', z.string().describe('Read new body from file (use - for stdin)'))
177
+ .option('--cc <cc>', z.string().describe('New CC recipients (comma-separated)'))
178
+ .option('--bcc <bcc>', z.string().describe('New BCC recipients (comma-separated)'))
179
+ .option('--from <from>', z.string().describe('Send-as alias email'))
180
+ .action(async (draftId, options) => {
181
+ const { client } = await getClient(options.account)
182
+
183
+ // Fetch existing draft to merge unchanged fields
184
+ const existing = await client.getDraft({ draftId })
185
+ if (existing instanceof Error) handleCommandError(existing)
186
+
187
+ let body = options.body
188
+ if (options.bodyFile) {
189
+ if (options.bodyFile === '-') {
190
+ const chunks: Buffer[] = []
191
+ for await (const chunk of process.stdin) {
192
+ chunks.push(chunk)
193
+ }
194
+ body = Buffer.concat(chunks).toString('utf-8')
195
+ } else {
196
+ body = fs.readFileSync(options.bodyFile, 'utf-8')
197
+ }
198
+ }
199
+
200
+ const parseEmails = (str: string) =>
201
+ str.split(',').map((e) => e.trim()).filter(Boolean).map((email) => ({ email }))
202
+
203
+ const result = await client.updateDraft({
204
+ draftId,
205
+ to: options.to ? parseEmails(options.to) : existing.to,
206
+ subject: options.subject ?? existing.message.subject,
207
+ body: body ?? existing.message.body,
208
+ cc: options.cc ? parseEmails(options.cc) : existing.cc,
209
+ bcc: options.bcc ? parseEmails(options.bcc) : existing.bcc,
210
+ threadId: existing.message.threadId || undefined,
211
+ fromEmail: options.from ?? existing.message.from.email,
212
+ })
213
+ if (result instanceof Error) handleCommandError(result)
214
+
215
+ out.printYaml(result)
216
+ out.success('Draft updated')
217
+ })
218
+
164
219
  // =========================================================================
165
220
  // draft send
166
221
  // =========================================================================
@@ -1,14 +1,14 @@
1
1
  // Filter commands: list, create, delete Gmail filters.
2
2
  // Multi-account support via getClients/getClient like label.ts.
3
3
 
4
- import type { Goke } from 'goke'
4
+ import type { ZeleCli } from '../cli-types.js'
5
5
  import { getClients } from '../auth.js'
6
6
  import { AuthError, UnsupportedError, isScopeError } from '../api-utils.js'
7
7
  import type { GmailClient } from '../gmail-client.js'
8
8
  import * as out from '../output.js'
9
9
  import { handleCommandError } from '../output.js'
10
10
 
11
- export function registerFilterCommands(cli: Goke) {
11
+ export function registerFilterCommands(cli: ZeleCli) {
12
12
  // =========================================================================
13
13
  // filter list
14
14
  // =========================================================================