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.
Files changed (64) hide show
  1. package/README.md +81 -12
  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 +58 -52
  14. package/dist/commands/auth-cmd.js.map +1 -1
  15. package/dist/commands/calendar.d.ts +2 -2
  16. package/dist/commands/calendar.js +13 -14
  17. package/dist/commands/calendar.js.map +1 -1
  18. package/dist/commands/draft.d.ts +2 -2
  19. package/dist/commands/draft.js +62 -15
  20. package/dist/commands/draft.js.map +1 -1
  21. package/dist/commands/filter.d.ts +2 -2
  22. package/dist/commands/filter.js.map +1 -1
  23. package/dist/commands/label.d.ts +2 -2
  24. package/dist/commands/label.js +5 -6
  25. package/dist/commands/label.js.map +1 -1
  26. package/dist/commands/mail-actions.d.ts +2 -2
  27. package/dist/commands/mail-actions.js +290 -1
  28. package/dist/commands/mail-actions.js.map +1 -1
  29. package/dist/commands/mail.d.ts +2 -2
  30. package/dist/commands/mail.js +50 -10
  31. package/dist/commands/mail.js.map +1 -1
  32. package/dist/commands/profile.d.ts +2 -2
  33. package/dist/commands/profile.js.map +1 -1
  34. package/dist/commands/watch.d.ts +2 -2
  35. package/dist/commands/watch.js +2 -2
  36. package/dist/commands/watch.js.map +1 -1
  37. package/dist/gmail-client.d.ts +59 -3
  38. package/dist/gmail-client.js +119 -5
  39. package/dist/gmail-client.js.map +1 -1
  40. package/dist/imap-smtp-client.d.ts +75 -4
  41. package/dist/imap-smtp-client.js +131 -7
  42. package/dist/imap-smtp-client.js.map +1 -1
  43. package/dist/unsubscribe.d.ts +76 -0
  44. package/dist/unsubscribe.js +224 -0
  45. package/dist/unsubscribe.js.map +1 -0
  46. package/package.json +3 -2
  47. package/skills/zele/SKILL.md +32 -124
  48. package/src/api-utils.ts +14 -0
  49. package/src/cli-types.ts +8 -0
  50. package/src/cli.ts +2 -7
  51. package/src/commands/attachment.ts +2 -2
  52. package/src/commands/auth-cmd.ts +66 -56
  53. package/src/commands/calendar.ts +15 -16
  54. package/src/commands/draft.ts +71 -17
  55. package/src/commands/filter.ts +2 -2
  56. package/src/commands/label.ts +7 -8
  57. package/src/commands/mail-actions.ts +315 -4
  58. package/src/commands/mail.ts +54 -12
  59. package/src/commands/profile.ts +2 -2
  60. package/src/commands/watch.ts +4 -4
  61. package/src/gmail-client.ts +193 -6
  62. package/src/imap-smtp-client.ts +186 -7
  63. package/src/unsubscribe.test.ts +487 -0
  64. package/src/unsubscribe.ts +255 -0
@@ -0,0 +1,255 @@
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
+ export interface MailtoSpec {
15
+ to: string
16
+ subject?: string
17
+ body?: string
18
+ cc?: string[]
19
+ }
20
+
21
+ export type UnsubscribeMechanism =
22
+ | { kind: 'one-click'; url: string }
23
+ | { kind: 'mailto'; mailto: MailtoSpec }
24
+ | { kind: 'url'; url: string }
25
+
26
+ export interface UnsubscribePlan {
27
+ /** Mechanisms in preference order: one-click > mailto > url. */
28
+ mechanisms: UnsubscribeMechanism[]
29
+ /** True when RFC 8058 one-click is available (both headers present, https URL). */
30
+ hasOneClick: boolean
31
+ /** True if DKIM passed, false if failed, null if unknown (e.g. IMAP, sent mail). */
32
+ dkimAuthentic: boolean | null
33
+ /** Non-fatal concerns the caller may want to surface. */
34
+ warnings: string[]
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Parsing
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Split a List-Unsubscribe header into its `<URI>` entries. Commas only
43
+ * separate entries when they are outside angle brackets; whitespace and
44
+ * line folding inside the header are tolerated per RFC 2369 §2.
45
+ */
46
+ export function parseListUnsubscribeEntries(header: string): string[] {
47
+ if (!header) return []
48
+ const entries: string[] = []
49
+ let depth = 0
50
+ let current = ''
51
+ for (const ch of header) {
52
+ if (ch === '<') {
53
+ depth++
54
+ current += ch
55
+ continue
56
+ }
57
+ if (ch === '>') {
58
+ depth = Math.max(0, depth - 1)
59
+ current += ch
60
+ continue
61
+ }
62
+ if (ch === ',' && depth === 0) {
63
+ entries.push(current)
64
+ current = ''
65
+ continue
66
+ }
67
+ current += ch
68
+ }
69
+ if (current.trim().length > 0) entries.push(current)
70
+
71
+ // Extract the URI inside each angle bracket pair. Anything outside is ignored
72
+ // (comments, trailing text) per RFC 2369 §2 guideline 2.
73
+ const result: string[] = []
74
+ for (const raw of entries) {
75
+ const match = raw.match(/<([^>]*)>/)
76
+ if (!match) continue
77
+ const uri = match[1]!.replace(/\s+/g, '').trim()
78
+ if (uri.length > 0) result.push(uri)
79
+ }
80
+ return result
81
+ }
82
+
83
+ /**
84
+ * Parse a mailto: URI into its target address and optional subject/body/cc.
85
+ * Returns null if the URI is not a valid mailto.
86
+ *
87
+ * Supports percent-encoding per RFC 6068. Note: `mailto:` is NOT
88
+ * application/x-www-form-urlencoded, so `+` is preserved literally (this
89
+ * matters for plus-addressing like `foo+tag@example.com`).
90
+ */
91
+ export function parseMailto(uri: string): MailtoSpec | null {
92
+ if (!/^mailto:/i.test(uri)) return null
93
+ const afterScheme = uri.slice('mailto:'.length)
94
+ const qIndex = afterScheme.indexOf('?')
95
+ const rawTo = qIndex === -1 ? afterScheme : afterScheme.slice(0, qIndex)
96
+ const query = qIndex === -1 ? '' : afterScheme.slice(qIndex + 1)
97
+
98
+ const decode = (s: string): string => {
99
+ try {
100
+ // RFC 6068: only percent-decode. Do NOT rewrite `+` → space.
101
+ return decodeURIComponent(s)
102
+ } catch {
103
+ return s
104
+ }
105
+ }
106
+
107
+ // Strip CRLF (defense-in-depth against header injection through mailto
108
+ // fields that get passed to SMTP/mimetext). Only body is allowed to keep
109
+ // newlines because it becomes message content.
110
+ const sanitizeHeader = (s: string) => s.replace(/[\r\n]/g, '').trim()
111
+
112
+ const to = sanitizeHeader(decode(rawTo))
113
+ if (!to) return null
114
+
115
+ const spec: MailtoSpec = { to }
116
+ if (!query) return spec
117
+
118
+ const cc: string[] = []
119
+ for (const pair of query.split('&')) {
120
+ if (!pair) continue
121
+ const eq = pair.indexOf('=')
122
+ const key = (eq === -1 ? pair : pair.slice(0, eq)).toLowerCase()
123
+ const value = eq === -1 ? '' : decode(pair.slice(eq + 1))
124
+ if (key === 'subject') spec.subject = sanitizeHeader(value)
125
+ else if (key === 'body') spec.body = value
126
+ else if (key === 'cc') {
127
+ for (const addr of value.split(',')) {
128
+ const trimmed = sanitizeHeader(addr)
129
+ if (trimmed) cc.push(trimmed)
130
+ }
131
+ }
132
+ }
133
+ if (cc.length > 0) spec.cc = cc
134
+ return spec
135
+ }
136
+
137
+ /**
138
+ * Parse a List-Unsubscribe-Post header value. Per RFC 8058 §5 the only legal
139
+ * value is exactly `List-Unsubscribe=One-Click`. Whitespace-tolerant.
140
+ */
141
+ export function parseListUnsubscribePost(header: string | undefined): boolean {
142
+ if (!header) return false
143
+ return header.replace(/\s+/g, '').toLowerCase() === 'list-unsubscribe=one-click'
144
+ }
145
+
146
+ /**
147
+ * Lightweight check for whether a message has any standardized unsubscribe
148
+ * mechanism advertised. Used by list views that want to surface a
149
+ * `can_unsubscribe` boolean without building a full plan. Returns true when
150
+ * List-Unsubscribe contains at least one valid mailto: or http(s): entry.
151
+ */
152
+ export function hasUnsubscribeMechanism(listUnsubscribe: string | null | undefined): boolean {
153
+ if (!listUnsubscribe) return false
154
+ const entries = parseListUnsubscribeEntries(listUnsubscribe)
155
+ return entries.some((e) => /^(mailto|https?):/i.test(e))
156
+ }
157
+
158
+ /**
159
+ * Check whether a message advertises RFC 8058 one-click unsubscribe: both
160
+ * List-Unsubscribe-Post=One-Click and at least one https: URL in
161
+ * List-Unsubscribe. Does not check DKIM — the executor is responsible for
162
+ * that gating at the point of actually POSTing.
163
+ */
164
+ export function hasOneClickUnsubscribe(
165
+ listUnsubscribe: string | null | undefined,
166
+ listUnsubscribePost: string | null | undefined,
167
+ ): boolean {
168
+ if (!parseListUnsubscribePost(listUnsubscribePost ?? undefined)) return false
169
+ if (!listUnsubscribe) return false
170
+ return parseListUnsubscribeEntries(listUnsubscribe).some((e) => /^https:/i.test(e))
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Planning
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Build an unsubscribe plan from the raw headers + DKIM authenticity flag.
179
+ *
180
+ * Preference order:
181
+ * 1. RFC 8058 one-click (List-Unsubscribe-Post present + https URL).
182
+ * Emitted first because it is the spec-preferred programmatic path.
183
+ * 2. The remaining entries in sender-declared order (RFC 2369 §2 says
184
+ * clients should prefer left-to-right order). Each http(s): entry
185
+ * becomes a `url` mechanism, each mailto: entry becomes a `mailto`.
186
+ */
187
+ export function planUnsubscribe({
188
+ listUnsubscribe,
189
+ listUnsubscribePost,
190
+ dkimAuthentic,
191
+ }: {
192
+ listUnsubscribe: string | undefined
193
+ listUnsubscribePost: string | undefined
194
+ /** True iff DKIM=pass on the message. Null when unknown (IMAP, sent mail). */
195
+ dkimAuthentic: boolean | null
196
+ }): UnsubscribePlan {
197
+ const warnings: string[] = []
198
+ const mechanisms: UnsubscribeMechanism[] = []
199
+
200
+ const entries = listUnsubscribe ? parseListUnsubscribeEntries(listUnsubscribe) : []
201
+ const postOneClick = parseListUnsubscribePost(listUnsubscribePost)
202
+
203
+ // First pass: RFC 8058 one-click extraction. Any https entries become
204
+ // one-click candidates if the Post header is present.
205
+ const oneClickUrls = new Set<string>()
206
+ let hasOneClick = false
207
+ if (postOneClick) {
208
+ const httpsEntries = entries.filter((e) => /^https:/i.test(e))
209
+ if (httpsEntries.length > 0) {
210
+ hasOneClick = true
211
+ for (const url of httpsEntries) {
212
+ mechanisms.push({ kind: 'one-click', url })
213
+ oneClickUrls.add(url)
214
+ }
215
+ } else if (entries.some((e) => /^http:/i.test(e))) {
216
+ warnings.push('List-Unsubscribe-Post is present but no https URL (only http); RFC 8058 requires https')
217
+ } else {
218
+ warnings.push('List-Unsubscribe-Post is present but no http(s) URL to POST to')
219
+ }
220
+ }
221
+
222
+ // Second pass: legacy fallbacks in sender-declared order (RFC 2369 §2
223
+ // left-to-right preference). http(s) URLs already claimed by one-click
224
+ // are skipped so we don't emit duplicate mechanisms.
225
+ for (const uri of entries) {
226
+ if (/^mailto:/i.test(uri)) {
227
+ const mailto = parseMailto(uri)
228
+ if (mailto) mechanisms.push({ kind: 'mailto', mailto })
229
+ continue
230
+ }
231
+ if (/^https?:/i.test(uri)) {
232
+ if (oneClickUrls.has(uri)) continue
233
+ mechanisms.push({ kind: 'url', url: uri })
234
+ continue
235
+ }
236
+ }
237
+
238
+ // DKIM safety notes for one-click. RFC 8058 §4 says the message SHOULD have
239
+ // a valid DKIM signature covering both headers; we approximate with a
240
+ // DKIM=pass verdict from the receiving MTA.
241
+ if (hasOneClick) {
242
+ if (dkimAuthentic === false) {
243
+ warnings.push('DKIM did not pass; one-click may be spoofed by an attacker')
244
+ } else if (dkimAuthentic === null) {
245
+ warnings.push('DKIM status unknown (no authentication info on this message)')
246
+ }
247
+ }
248
+
249
+ return {
250
+ mechanisms,
251
+ hasOneClick,
252
+ dkimAuthentic,
253
+ warnings,
254
+ }
255
+ }