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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zele",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.20",
|
|
4
|
+
"description": "Email & Calendar CLI — Gmail, IMAP/SMTP, Google Calendar from your terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/cli.js",
|
|
7
7
|
"bin": "./dist/cli.js",
|
|
@@ -35,11 +35,13 @@
|
|
|
35
35
|
"email-reply-parser": "^2.3.5",
|
|
36
36
|
"errore": "^0.11.0",
|
|
37
37
|
"fkill": "^10.0.3",
|
|
38
|
-
"goke": "^6.
|
|
38
|
+
"goke": "^6.6.0",
|
|
39
39
|
"google-auth-library": "^10.5.0",
|
|
40
|
+
"imapflow": "^1.2.18",
|
|
40
41
|
"js-yaml": "^4.1.1",
|
|
41
42
|
"mimetext": "^3.0.28",
|
|
42
43
|
"mrmime": "^2.0.1",
|
|
44
|
+
"nodemailer": "^8.0.4",
|
|
43
45
|
"picocolors": "^1.1.1",
|
|
44
46
|
"react": "^19.2.4",
|
|
45
47
|
"remark": "^15.0.1",
|
|
@@ -61,6 +63,7 @@
|
|
|
61
63
|
"@types/email-addresses": "^3.0.0",
|
|
62
64
|
"@types/js-yaml": "^4.0.9",
|
|
63
65
|
"@types/node": "^25.2.0",
|
|
66
|
+
"@types/nodemailer": "^8.0.0",
|
|
64
67
|
"@types/react": "^19.1.2",
|
|
65
68
|
"@types/turndown": "^5.0.5",
|
|
66
69
|
"prisma": "7.3.0",
|
package/schema.prisma
CHANGED
|
@@ -13,15 +13,17 @@ enum AccountStatus {
|
|
|
13
13
|
disabled
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
// Stores one
|
|
17
|
-
// appId is the Google OAuth client ID
|
|
18
|
-
//
|
|
19
|
-
//
|
|
16
|
+
// Stores one credential set per (email, appId) pair.
|
|
17
|
+
// appId is the Google OAuth client ID for Google accounts, or 'imap_smtp' for IMAP/SMTP accounts.
|
|
18
|
+
// accountType discriminates the credential format stored in tokens.
|
|
19
|
+
// capabilities is a comma-separated list of features: "gmail,calendar,smtp" or "imap,smtp".
|
|
20
20
|
model Account {
|
|
21
21
|
email String
|
|
22
22
|
appId String
|
|
23
|
+
accountType String @default("google") // "google" | "imap_smtp"
|
|
24
|
+
capabilities String @default("") // comma-separated: "gmail,calendar,smtp" or "imap,smtp" or "imap"
|
|
23
25
|
accountStatus AccountStatus
|
|
24
|
-
tokens String // JSON
|
|
26
|
+
tokens String // JSON: OAuth2 Credentials (google) or ImapSmtpCredentials (imap_smtp)
|
|
25
27
|
createdAt DateTime
|
|
26
28
|
updatedAt DateTime @updatedAt
|
|
27
29
|
|
package/skills/zele/SKILL.md
CHANGED
|
@@ -1,112 +1,42 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: zele
|
|
3
3
|
description: >
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
8
17
|
---
|
|
9
18
|
|
|
10
|
-
# zele
|
|
19
|
+
# zele
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## Setup
|
|
21
|
+
Every time you use zele, you MUST fetch the latest README:
|
|
15
22
|
|
|
16
23
|
```bash
|
|
17
|
-
#
|
|
18
|
-
bun install -g zele
|
|
19
|
-
|
|
20
|
-
# show connected accounts
|
|
21
|
-
zele whoami
|
|
22
|
-
|
|
23
|
-
# authenticate (opens browser, supports multiple accounts)
|
|
24
|
-
zele login
|
|
24
|
+
curl -s https://raw.githubusercontent.com/remorses/zele/main/README.md # NEVER pipe to head/tail, read the full output
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
# start login in a tmux session
|
|
31
|
-
tmux new-session -d -s zele-login 'zele login'
|
|
32
|
-
|
|
33
|
-
# read the authorization URL from tmux output
|
|
34
|
-
tmux capture-pane -t zele-login -p
|
|
35
|
-
|
|
36
|
-
# after the user completes consent in their browser, paste the redirect URL
|
|
37
|
-
tmux send-keys -t zele-login 'http://localhost:...?code=...' Enter
|
|
38
|
-
|
|
39
|
-
# verify login succeeded
|
|
40
|
-
tmux capture-pane -t zele-login -p
|
|
41
|
-
tmux kill-session -t zele-login
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Important
|
|
45
|
-
|
|
46
|
-
**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.
|
|
47
|
-
|
|
48
|
-
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.
|
|
49
|
-
|
|
50
|
-
## Capabilities
|
|
51
|
-
|
|
52
|
-
- **Mail:** list, search, read, send, reply, forward, star, archive, trash, label, watch for new emails, manage filters
|
|
53
|
-
- **Drafts:** list, create, get, send, delete
|
|
54
|
-
- **Calendar:** list calendars, list/search events, create/update/delete events, RSVP, free/busy
|
|
55
|
-
- **Labels:** list, create, delete, unread counts
|
|
56
|
-
- **Attachments:** list per thread, download
|
|
57
|
-
- **Multi-account:** all commands support `--account <email>` to filter; list/search merge across accounts
|
|
58
|
-
|
|
59
|
-
## Account discovery
|
|
60
|
-
|
|
61
|
-
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.
|
|
27
|
+
Then run the CLI help once — it already includes every subcommand, option, and flag:
|
|
62
28
|
|
|
63
29
|
```bash
|
|
64
|
-
#
|
|
65
|
-
zele whoami
|
|
66
|
-
|
|
67
|
-
# then use the email from the output
|
|
68
|
-
zele mail list --account user@work.com
|
|
30
|
+
zele --help # NEVER pipe to head/tail, read the full output
|
|
69
31
|
```
|
|
70
32
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
```bash
|
|
74
|
-
# list inbox
|
|
75
|
-
zele mail list
|
|
76
|
-
|
|
77
|
-
# list only unread emails
|
|
78
|
-
zele mail list --filter "is:unread"
|
|
79
|
-
|
|
80
|
-
# list unread emails with attachments in inbox
|
|
81
|
-
zele mail list --filter "is:unread has:attachment"
|
|
82
|
-
|
|
83
|
-
# combine filter with folder
|
|
84
|
-
zele mail list --filter "from:github" --folder sent
|
|
85
|
-
|
|
86
|
-
# search mail
|
|
87
|
-
zele mail search "from:github subject:review"
|
|
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.
|
|
88
34
|
|
|
89
|
-
|
|
90
|
-
zele mail read <threadId>
|
|
35
|
+
## Rules
|
|
91
36
|
|
|
92
|
-
|
|
93
|
-
zele
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
zele
|
|
97
|
-
|
|
98
|
-
# watch inbox for new mail (polls every 15s)
|
|
99
|
-
zele mail watch
|
|
100
|
-
|
|
101
|
-
# today's calendar events across all accounts
|
|
102
|
-
zele cal events --today --all
|
|
103
|
-
|
|
104
|
-
# create a meeting with Google Meet
|
|
105
|
-
zele cal create --summary "Standup" --from tomorrow --to +30m --meet --attendees bob@example.com
|
|
106
|
-
|
|
107
|
-
# check free/busy
|
|
108
|
-
zele cal freebusy --from today --to +8h
|
|
109
|
-
|
|
110
|
-
# list Gmail filters
|
|
111
|
-
zele mail filter list
|
|
112
|
-
```
|
|
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
|
@@ -120,6 +120,26 @@ export class ApiError extends errore.createTaggedError({
|
|
|
120
120
|
message: 'API call failed: $reason',
|
|
121
121
|
}) {}
|
|
122
122
|
|
|
123
|
+
/** Returned when a command requires a capability (e.g. gmail labels) that the account doesn't support. */
|
|
124
|
+
export class UnsupportedError extends errore.createTaggedError({
|
|
125
|
+
name: 'UnsupportedError',
|
|
126
|
+
message: '$feature is not available for $accountType accounts. $hint',
|
|
127
|
+
}) {}
|
|
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
|
+
|
|
123
143
|
/** Detect auth-like errors from underlying libraries (tsdav string errors, googleapis structured errors).
|
|
124
144
|
* Used inside clients to decide whether to return an AuthError.
|
|
125
145
|
* NOTE: String matching here is intentional — this is the boundary layer that converts
|
package/src/auth.ts
CHANGED
|
@@ -16,7 +16,51 @@ import { getPrisma } from './db.js'
|
|
|
16
16
|
import { GmailClient } from './gmail-client.js'
|
|
17
17
|
import { CalendarClient } from './calendar-client.js'
|
|
18
18
|
import * as errore from 'errore'
|
|
19
|
-
import { AuthError } from './api-utils.js'
|
|
19
|
+
import { AuthError, ApiError, UnsupportedError } from './api-utils.js'
|
|
20
|
+
import { ImapSmtpClient } from './imap-smtp-client.js'
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Account types
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export type AccountType = 'google' | 'imap_smtp'
|
|
27
|
+
|
|
28
|
+
export const IMAP_SMTP_APP_ID = 'imap_smtp' as const
|
|
29
|
+
|
|
30
|
+
export interface ImapCredentials {
|
|
31
|
+
host: string
|
|
32
|
+
port: number
|
|
33
|
+
user: string
|
|
34
|
+
password: string
|
|
35
|
+
tls: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SmtpCredentials {
|
|
39
|
+
host: string
|
|
40
|
+
port: number
|
|
41
|
+
user: string
|
|
42
|
+
password: string
|
|
43
|
+
tls: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Stored in the `tokens` column for imap_smtp accounts. */
|
|
47
|
+
export interface ImapSmtpCredentials {
|
|
48
|
+
imap?: ImapCredentials
|
|
49
|
+
smtp?: SmtpCredentials
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Capabilities an account can have. */
|
|
53
|
+
export type AccountCapability = 'gmail' | 'calendar' | 'smtp' | 'imap'
|
|
54
|
+
|
|
55
|
+
export function parseCapabilities(raw: string): AccountCapability[] {
|
|
56
|
+
if (!raw) return []
|
|
57
|
+
return raw.split(',').map((s) => s.trim()).filter(Boolean) as AccountCapability[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function hasCapability(capabilities: string | AccountCapability[], cap: AccountCapability): boolean {
|
|
61
|
+
const list = typeof capabilities === 'string' ? parseCapabilities(capabilities) : capabilities
|
|
62
|
+
return list.includes(cap)
|
|
63
|
+
}
|
|
20
64
|
|
|
21
65
|
// ---------------------------------------------------------------------------
|
|
22
66
|
// Known open-source Google OAuth clients (Desktop app type).
|
|
@@ -123,6 +167,8 @@ export function createOAuth2Client(appId?: string): OAuth2Client {
|
|
|
123
167
|
export interface AccountId {
|
|
124
168
|
email: string
|
|
125
169
|
appId: string
|
|
170
|
+
accountType: AccountType
|
|
171
|
+
capabilities: AccountCapability[]
|
|
126
172
|
}
|
|
127
173
|
|
|
128
174
|
// ---------------------------------------------------------------------------
|
|
@@ -530,6 +576,7 @@ export async function login(
|
|
|
530
576
|
|
|
531
577
|
// Upsert account in DB
|
|
532
578
|
const prisma = await getPrisma()
|
|
579
|
+
const googleCapabilities = 'gmail,calendar,smtp'
|
|
533
580
|
const upsertResult = await errore.tryAsync({
|
|
534
581
|
try: () =>
|
|
535
582
|
prisma.account.upsert({
|
|
@@ -537,12 +584,14 @@ export async function login(
|
|
|
537
584
|
create: {
|
|
538
585
|
email,
|
|
539
586
|
appId: resolved.clientId,
|
|
587
|
+
accountType: 'google',
|
|
588
|
+
capabilities: googleCapabilities,
|
|
540
589
|
accountStatus: 'active',
|
|
541
590
|
tokens: JSON.stringify(tokens),
|
|
542
591
|
createdAt: new Date(),
|
|
543
592
|
updatedAt: new Date(),
|
|
544
593
|
},
|
|
545
|
-
update: { tokens: JSON.stringify(tokens), updatedAt: new Date() },
|
|
594
|
+
update: { tokens: JSON.stringify(tokens), capabilities: googleCapabilities, updatedAt: new Date() },
|
|
546
595
|
}),
|
|
547
596
|
catch: (err) => new Error(`Failed to save account ${email}`, { cause: err }),
|
|
548
597
|
})
|
|
@@ -551,6 +600,143 @@ export async function login(
|
|
|
551
600
|
return { email, appId: resolved.clientId, client }
|
|
552
601
|
}
|
|
553
602
|
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// Login: IMAP/SMTP credentials → save to DB
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
export interface LoginImapOptions {
|
|
608
|
+
email: string
|
|
609
|
+
imapHost: string
|
|
610
|
+
imapPort?: number
|
|
611
|
+
smtpHost?: string
|
|
612
|
+
smtpPort?: number
|
|
613
|
+
password?: string
|
|
614
|
+
imapUser?: string
|
|
615
|
+
imapPassword?: string
|
|
616
|
+
smtpUser?: string
|
|
617
|
+
smtpPassword?: string
|
|
618
|
+
tls?: boolean
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Login with IMAP/SMTP credentials. Tests connections before saving.
|
|
623
|
+
* Returns an error value if validation or connection test fails.
|
|
624
|
+
*/
|
|
625
|
+
export async function loginImap(
|
|
626
|
+
options: LoginImapOptions,
|
|
627
|
+
): Promise<{ email: string; appId: string } | Error> {
|
|
628
|
+
const {
|
|
629
|
+
email,
|
|
630
|
+
imapHost,
|
|
631
|
+
imapPort = 993,
|
|
632
|
+
smtpHost,
|
|
633
|
+
smtpPort = 465,
|
|
634
|
+
password,
|
|
635
|
+
imapUser,
|
|
636
|
+
imapPassword,
|
|
637
|
+
smtpUser,
|
|
638
|
+
smtpPassword,
|
|
639
|
+
tls = true,
|
|
640
|
+
} = options
|
|
641
|
+
|
|
642
|
+
const imapPass = imapPassword ?? password
|
|
643
|
+
if (!imapPass) {
|
|
644
|
+
return new Error('IMAP password is required (--password or --imap-password)')
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const credentials: ImapSmtpCredentials = {
|
|
648
|
+
imap: {
|
|
649
|
+
host: imapHost,
|
|
650
|
+
port: imapPort,
|
|
651
|
+
user: imapUser ?? email,
|
|
652
|
+
password: imapPass,
|
|
653
|
+
tls,
|
|
654
|
+
},
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Test IMAP connection
|
|
658
|
+
const { ImapFlow } = await import('imapflow')
|
|
659
|
+
const testClient = new ImapFlow({
|
|
660
|
+
host: imapHost,
|
|
661
|
+
port: imapPort,
|
|
662
|
+
secure: tls,
|
|
663
|
+
auth: { user: imapUser ?? email, pass: imapPass },
|
|
664
|
+
logger: false,
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
const imapTest = await errore.tryAsync({
|
|
668
|
+
try: async () => {
|
|
669
|
+
await testClient.connect()
|
|
670
|
+
await testClient.logout()
|
|
671
|
+
},
|
|
672
|
+
catch: (err) => new AuthError({ email, reason: `IMAP connection failed: ${String(err)}` }),
|
|
673
|
+
})
|
|
674
|
+
if (imapTest instanceof Error) return imapTest
|
|
675
|
+
|
|
676
|
+
// Configure SMTP if provided
|
|
677
|
+
const capabilities: AccountCapability[] = ['imap']
|
|
678
|
+
|
|
679
|
+
if (smtpHost) {
|
|
680
|
+
const smtpPass = smtpPassword ?? password
|
|
681
|
+
if (!smtpPass) {
|
|
682
|
+
return new Error('SMTP password is required when --smtp-host is provided (--password or --smtp-password)')
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
credentials.smtp = {
|
|
686
|
+
host: smtpHost,
|
|
687
|
+
port: smtpPort,
|
|
688
|
+
user: smtpUser ?? email,
|
|
689
|
+
password: smtpPass,
|
|
690
|
+
tls: smtpPort === 465,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Test SMTP connection
|
|
694
|
+
const nodemailer = await import('nodemailer')
|
|
695
|
+
const transporter = nodemailer.default.createTransport({
|
|
696
|
+
host: smtpHost,
|
|
697
|
+
port: smtpPort,
|
|
698
|
+
secure: smtpPort === 465,
|
|
699
|
+
auth: { user: smtpUser ?? email, pass: smtpPass },
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
const smtpTest = await errore.tryAsync({
|
|
703
|
+
try: () => transporter.verify(),
|
|
704
|
+
catch: (err) => new AuthError({ email, reason: `SMTP connection failed: ${String(err)}` }),
|
|
705
|
+
})
|
|
706
|
+
if (smtpTest instanceof Error) return smtpTest
|
|
707
|
+
|
|
708
|
+
capabilities.push('smtp')
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Save to DB
|
|
712
|
+
const prisma = await getPrisma()
|
|
713
|
+
const upsertResult = await errore.tryAsync({
|
|
714
|
+
try: () =>
|
|
715
|
+
prisma.account.upsert({
|
|
716
|
+
where: { email_appId: { email, appId: IMAP_SMTP_APP_ID } },
|
|
717
|
+
create: {
|
|
718
|
+
email,
|
|
719
|
+
appId: IMAP_SMTP_APP_ID,
|
|
720
|
+
accountType: 'imap_smtp',
|
|
721
|
+
capabilities: capabilities.join(','),
|
|
722
|
+
accountStatus: 'active',
|
|
723
|
+
tokens: JSON.stringify(credentials),
|
|
724
|
+
createdAt: new Date(),
|
|
725
|
+
updatedAt: new Date(),
|
|
726
|
+
},
|
|
727
|
+
update: {
|
|
728
|
+
capabilities: capabilities.join(','),
|
|
729
|
+
tokens: JSON.stringify(credentials),
|
|
730
|
+
updatedAt: new Date(),
|
|
731
|
+
},
|
|
732
|
+
}),
|
|
733
|
+
catch: (err) => new Error(`Failed to save account ${email}`, { cause: err }),
|
|
734
|
+
})
|
|
735
|
+
if (upsertResult instanceof Error) return upsertResult
|
|
736
|
+
|
|
737
|
+
return { email, appId: IMAP_SMTP_APP_ID }
|
|
738
|
+
}
|
|
739
|
+
|
|
554
740
|
// ---------------------------------------------------------------------------
|
|
555
741
|
// Logout: remove account from DB
|
|
556
742
|
// ---------------------------------------------------------------------------
|
|
@@ -571,8 +757,13 @@ export async function logout(email: string): Promise<void | Error> {
|
|
|
571
757
|
|
|
572
758
|
export async function listAccounts(): Promise<AccountId[]> {
|
|
573
759
|
const prisma = await getPrisma()
|
|
574
|
-
const rows = await prisma.account.findMany({ select: { email: true, appId: true } })
|
|
575
|
-
return rows.map((r) => ({
|
|
760
|
+
const rows = await prisma.account.findMany({ select: { email: true, appId: true, accountType: true, capabilities: true } })
|
|
761
|
+
return rows.map((r) => ({
|
|
762
|
+
email: r.email,
|
|
763
|
+
appId: r.appId,
|
|
764
|
+
accountType: r.accountType as AccountType,
|
|
765
|
+
capabilities: parseCapabilities(r.capabilities),
|
|
766
|
+
}))
|
|
576
767
|
}
|
|
577
768
|
|
|
578
769
|
// ---------------------------------------------------------------------------
|
|
@@ -613,13 +804,23 @@ async function authenticateAccount(account: AccountId): Promise<OAuth2Client> {
|
|
|
613
804
|
return oauth2Client
|
|
614
805
|
}
|
|
615
806
|
|
|
807
|
+
/** Entry returned by getClients — client is GmailClient for Google accounts, ImapSmtpClient for IMAP/SMTP. */
|
|
808
|
+
export interface ClientEntry {
|
|
809
|
+
email: string
|
|
810
|
+
appId: string
|
|
811
|
+
accountType: AccountType
|
|
812
|
+
capabilities: AccountCapability[]
|
|
813
|
+
client: GmailClient | ImapSmtpClient
|
|
814
|
+
}
|
|
815
|
+
|
|
616
816
|
/**
|
|
617
|
-
* Get authenticated
|
|
817
|
+
* Get authenticated client instances for all accounts (or filtered by email list).
|
|
818
|
+
* Returns GmailClient for Google accounts and ImapSmtpClient for IMAP/SMTP accounts.
|
|
618
819
|
* If no accounts are registered, throws with a helpful message.
|
|
619
820
|
*/
|
|
620
821
|
export async function getClients(
|
|
621
822
|
accounts?: string[],
|
|
622
|
-
): Promise<
|
|
823
|
+
): Promise<ClientEntry[]> {
|
|
623
824
|
const allAccounts = await listAccounts()
|
|
624
825
|
if (allAccounts.length === 0) {
|
|
625
826
|
throw new Error('No accounts registered. Run: zele login')
|
|
@@ -634,10 +835,33 @@ export async function getClients(
|
|
|
634
835
|
throw new Error(`No matching accounts. Available: ${available}`)
|
|
635
836
|
}
|
|
636
837
|
|
|
838
|
+
const prisma = await getPrisma()
|
|
637
839
|
const results = await Promise.all(
|
|
638
|
-
filtered.map(async (account) => {
|
|
840
|
+
filtered.map(async (account): Promise<ClientEntry> => {
|
|
841
|
+
if (account.accountType === 'imap_smtp') {
|
|
842
|
+
const row = await prisma.account.findUnique({
|
|
843
|
+
where: { email_appId: { email: account.email, appId: account.appId } },
|
|
844
|
+
})
|
|
845
|
+
if (!row) throw new Error(`No account found for ${account.email}. Run: zele login`)
|
|
846
|
+
const credentials: ImapSmtpCredentials = JSON.parse(row.tokens)
|
|
847
|
+
return {
|
|
848
|
+
email: account.email,
|
|
849
|
+
appId: account.appId,
|
|
850
|
+
accountType: 'imap_smtp',
|
|
851
|
+
capabilities: account.capabilities,
|
|
852
|
+
client: new ImapSmtpClient({ credentials, account }),
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Google account
|
|
639
857
|
const auth = await authenticateAccount(account)
|
|
640
|
-
return {
|
|
858
|
+
return {
|
|
859
|
+
email: account.email,
|
|
860
|
+
appId: account.appId,
|
|
861
|
+
accountType: 'google',
|
|
862
|
+
capabilities: account.capabilities,
|
|
863
|
+
client: new GmailClient({ auth, account }),
|
|
864
|
+
}
|
|
641
865
|
}),
|
|
642
866
|
)
|
|
643
867
|
|
|
@@ -645,12 +869,12 @@ export async function getClients(
|
|
|
645
869
|
}
|
|
646
870
|
|
|
647
871
|
/**
|
|
648
|
-
* Get a single authenticated
|
|
872
|
+
* Get a single authenticated client. Errors if multiple accounts exist
|
|
649
873
|
* and no --account filter was provided.
|
|
650
874
|
*/
|
|
651
875
|
export async function getClient(
|
|
652
876
|
accounts?: string[],
|
|
653
|
-
): Promise<
|
|
877
|
+
): Promise<ClientEntry> {
|
|
654
878
|
const clients = await getClients(accounts)
|
|
655
879
|
if (clients.length === 1) {
|
|
656
880
|
return clients[0]!
|
|
@@ -662,12 +886,31 @@ export async function getClient(
|
|
|
662
886
|
)
|
|
663
887
|
}
|
|
664
888
|
|
|
889
|
+
/**
|
|
890
|
+
* Get a single authenticated GmailClient. Errors if account is not a Google account.
|
|
891
|
+
* Use this for commands that require Gmail-specific features.
|
|
892
|
+
*/
|
|
893
|
+
export async function getGmailClient(
|
|
894
|
+
accounts?: string[],
|
|
895
|
+
): Promise<{ email: string; appId: string; client: GmailClient }> {
|
|
896
|
+
const entry = await getClient(accounts)
|
|
897
|
+
if (entry.accountType !== 'google') {
|
|
898
|
+
throw new UnsupportedError({
|
|
899
|
+
feature: 'This command',
|
|
900
|
+
accountType: 'IMAP/SMTP',
|
|
901
|
+
hint: 'It requires a Google account.',
|
|
902
|
+
})
|
|
903
|
+
}
|
|
904
|
+
return { email: entry.email, appId: entry.appId, client: entry.client as GmailClient }
|
|
905
|
+
}
|
|
906
|
+
|
|
665
907
|
// ---------------------------------------------------------------------------
|
|
666
908
|
// Calendar client helpers
|
|
667
909
|
// ---------------------------------------------------------------------------
|
|
668
910
|
|
|
669
911
|
/**
|
|
670
|
-
* Get authenticated CalendarClient instances for
|
|
912
|
+
* Get authenticated CalendarClient instances for Google accounts only.
|
|
913
|
+
* IMAP/SMTP accounts are silently skipped (calendar requires Google OAuth).
|
|
671
914
|
*/
|
|
672
915
|
export async function getCalendarClients(
|
|
673
916
|
accounts?: string[],
|
|
@@ -686,8 +929,18 @@ export async function getCalendarClients(
|
|
|
686
929
|
throw new Error(`No matching accounts. Available: ${available}`)
|
|
687
930
|
}
|
|
688
931
|
|
|
932
|
+
// Only Google accounts support calendar
|
|
933
|
+
const googleAccounts = filtered.filter((a) => a.accountType === 'google')
|
|
934
|
+
if (googleAccounts.length === 0) {
|
|
935
|
+
throw new UnsupportedError({
|
|
936
|
+
feature: 'Calendar',
|
|
937
|
+
accountType: 'IMAP/SMTP',
|
|
938
|
+
hint: 'Calendar requires a Google account.',
|
|
939
|
+
})
|
|
940
|
+
}
|
|
941
|
+
|
|
689
942
|
const results = await Promise.all(
|
|
690
|
-
|
|
943
|
+
googleAccounts.map(async (account) => {
|
|
691
944
|
const auth = await authenticateAccount(account)
|
|
692
945
|
const { token } = await auth.getAccessToken()
|
|
693
946
|
if (!token) throw new Error(`Failed to get access token for ${account.email}`)
|
|
@@ -723,6 +976,8 @@ export async function getCalendarClient(
|
|
|
723
976
|
export interface AuthStatus {
|
|
724
977
|
email: string
|
|
725
978
|
appId: string
|
|
979
|
+
accountType: AccountType
|
|
980
|
+
capabilities: AccountCapability[]
|
|
726
981
|
expiresAt?: Date
|
|
727
982
|
}
|
|
728
983
|
|
|
@@ -731,11 +986,24 @@ export async function getAuthStatuses(): Promise<AuthStatus[]> {
|
|
|
731
986
|
const rows = await prisma.account.findMany()
|
|
732
987
|
|
|
733
988
|
return rows.map((row) => {
|
|
734
|
-
const
|
|
989
|
+
const accountType = row.accountType as AccountType
|
|
990
|
+
const capabilities = parseCapabilities(row.capabilities)
|
|
991
|
+
if (accountType === 'google') {
|
|
992
|
+
const tokens: Credentials = JSON.parse(row.tokens)
|
|
993
|
+
return {
|
|
994
|
+
email: row.email,
|
|
995
|
+
appId: row.appId,
|
|
996
|
+
accountType,
|
|
997
|
+
capabilities,
|
|
998
|
+
expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
// IMAP/SMTP — no expiry
|
|
735
1002
|
return {
|
|
736
1003
|
email: row.email,
|
|
737
1004
|
appId: row.appId,
|
|
738
|
-
|
|
1005
|
+
accountType,
|
|
1006
|
+
capabilities,
|
|
739
1007
|
}
|
|
740
1008
|
})
|
|
741
1009
|
}
|
package/src/cli-types.ts
ADDED
|
@@ -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
|
)
|