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.
- package/README.md +155 -36
- package/dist/api-utils.d.ts +14 -0
- package/dist/api-utils.js +20 -0
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +71 -9
- package/dist/auth.js +186 -10
- package/dist/auth.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 +2 -0
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.d.ts +2 -2
- package/dist/commands/auth-cmd.js +104 -6
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.d.ts +2 -2
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.d.ts +2 -2
- package/dist/commands/draft.js +58 -4
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/filter.d.ts +2 -2
- package/dist/commands/filter.js +7 -2
- package/dist/commands/filter.js.map +1 -1
- package/dist/commands/label.d.ts +2 -2
- package/dist/commands/label.js +19 -9
- 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 +90 -23
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.d.ts +2 -2
- package/dist/commands/profile.js +25 -18
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.d.ts +2 -2
- package/dist/commands/watch.js.map +1 -1
- package/dist/db.js +24 -0
- package/dist/db.js.map +1 -1
- package/dist/generated/internal/class.js +2 -2
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +2 -0
- package/dist/generated/internal/prismaNamespace.js +2 -0
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +97 -1
- package/dist/gmail-client.d.ts +73 -3
- package/dist/gmail-client.js +165 -5
- package/dist/gmail-client.js.map +1 -1
- package/dist/imap-smtp-client.d.ts +306 -0
- package/dist/imap-smtp-client.js +1349 -0
- package/dist/imap-smtp-client.js.map +1 -0
- package/dist/mail-tui.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 +6 -3
- package/schema.prisma +7 -5
- package/skills/zele/SKILL.md +26 -96
- package/src/api-utils.ts +20 -0
- package/src/auth.ts +282 -14
- package/src/cli-types.ts +8 -0
- package/src/cli.ts +2 -7
- package/src/commands/attachment.ts +3 -2
- package/src/commands/auth-cmd.ts +114 -8
- package/src/commands/calendar.ts +2 -2
- package/src/commands/draft.ts +65 -6
- package/src/commands/filter.ts +11 -5
- package/src/commands/label.ts +24 -13
- package/src/commands/mail-actions.ts +317 -5
- package/src/commands/mail.ts +97 -25
- package/src/commands/profile.ts +29 -19
- package/src/commands/watch.ts +2 -2
- package/src/db.ts +28 -0
- package/src/generated/internal/class.ts +2 -2
- package/src/generated/internal/prismaNamespace.ts +2 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
- package/src/generated/models/Account.ts +97 -1
- package/src/gmail-client.test.ts +155 -2
- package/src/gmail-client.ts +258 -6
- package/src/imap-smtp-client.ts +1560 -0
- package/src/mail-tui.tsx +2 -1
- package/src/schema.sql +2 -0
- package/src/unsubscribe.test.ts +487 -0
- 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
|
+
}
|