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.
Files changed (90) hide show
  1. package/README.md +155 -36
  2. package/dist/api-utils.d.ts +14 -0
  3. package/dist/api-utils.js +20 -0
  4. package/dist/api-utils.js.map +1 -1
  5. package/dist/auth.d.ts +71 -9
  6. package/dist/auth.js +186 -10
  7. package/dist/auth.js.map +1 -1
  8. package/dist/cli-types.d.ts +4 -0
  9. package/dist/cli-types.js +6 -0
  10. package/dist/cli-types.js.map +1 -0
  11. package/dist/cli.js +1 -5
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/attachment.d.ts +2 -2
  14. package/dist/commands/attachment.js +2 -0
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.d.ts +2 -2
  17. package/dist/commands/auth-cmd.js +104 -6
  18. package/dist/commands/auth-cmd.js.map +1 -1
  19. package/dist/commands/calendar.d.ts +2 -2
  20. package/dist/commands/calendar.js.map +1 -1
  21. package/dist/commands/draft.d.ts +2 -2
  22. package/dist/commands/draft.js +58 -4
  23. package/dist/commands/draft.js.map +1 -1
  24. package/dist/commands/filter.d.ts +2 -2
  25. package/dist/commands/filter.js +7 -2
  26. package/dist/commands/filter.js.map +1 -1
  27. package/dist/commands/label.d.ts +2 -2
  28. package/dist/commands/label.js +19 -9
  29. package/dist/commands/label.js.map +1 -1
  30. package/dist/commands/mail-actions.d.ts +2 -2
  31. package/dist/commands/mail-actions.js +290 -1
  32. package/dist/commands/mail-actions.js.map +1 -1
  33. package/dist/commands/mail.d.ts +2 -2
  34. package/dist/commands/mail.js +90 -23
  35. package/dist/commands/mail.js.map +1 -1
  36. package/dist/commands/profile.d.ts +2 -2
  37. package/dist/commands/profile.js +25 -18
  38. package/dist/commands/profile.js.map +1 -1
  39. package/dist/commands/watch.d.ts +2 -2
  40. package/dist/commands/watch.js.map +1 -1
  41. package/dist/db.js +24 -0
  42. package/dist/db.js.map +1 -1
  43. package/dist/generated/internal/class.js +2 -2
  44. package/dist/generated/internal/class.js.map +1 -1
  45. package/dist/generated/internal/prismaNamespace.d.ts +2 -0
  46. package/dist/generated/internal/prismaNamespace.js +2 -0
  47. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  48. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +2 -0
  49. package/dist/generated/internal/prismaNamespaceBrowser.js +2 -0
  50. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  51. package/dist/generated/models/Account.d.ts +97 -1
  52. package/dist/gmail-client.d.ts +73 -3
  53. package/dist/gmail-client.js +165 -5
  54. package/dist/gmail-client.js.map +1 -1
  55. package/dist/imap-smtp-client.d.ts +306 -0
  56. package/dist/imap-smtp-client.js +1349 -0
  57. package/dist/imap-smtp-client.js.map +1 -0
  58. package/dist/mail-tui.js.map +1 -1
  59. package/dist/unsubscribe.d.ts +76 -0
  60. package/dist/unsubscribe.js +224 -0
  61. package/dist/unsubscribe.js.map +1 -0
  62. package/package.json +6 -3
  63. package/schema.prisma +7 -5
  64. package/skills/zele/SKILL.md +26 -96
  65. package/src/api-utils.ts +20 -0
  66. package/src/auth.ts +282 -14
  67. package/src/cli-types.ts +8 -0
  68. package/src/cli.ts +2 -7
  69. package/src/commands/attachment.ts +3 -2
  70. package/src/commands/auth-cmd.ts +114 -8
  71. package/src/commands/calendar.ts +2 -2
  72. package/src/commands/draft.ts +65 -6
  73. package/src/commands/filter.ts +11 -5
  74. package/src/commands/label.ts +24 -13
  75. package/src/commands/mail-actions.ts +317 -5
  76. package/src/commands/mail.ts +97 -25
  77. package/src/commands/profile.ts +29 -19
  78. package/src/commands/watch.ts +2 -2
  79. package/src/db.ts +28 -0
  80. package/src/generated/internal/class.ts +2 -2
  81. package/src/generated/internal/prismaNamespace.ts +2 -0
  82. package/src/generated/internal/prismaNamespaceBrowser.ts +2 -0
  83. package/src/generated/models/Account.ts +97 -1
  84. package/src/gmail-client.test.ts +155 -2
  85. package/src/gmail-client.ts +258 -6
  86. package/src/imap-smtp-client.ts +1560 -0
  87. package/src/mail-tui.tsx +2 -1
  88. package/src/schema.sql +2 -0
  89. package/src/unsubscribe.test.ts +487 -0
  90. package/src/unsubscribe.ts +255 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "zele",
3
- "version": "0.3.16",
4
- "description": "Gmail CLI — manage email from your terminal",
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.1.3",
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 OAuth credential set per (email, appId) pair.
17
- // appId is the Google OAuth client ID used during login, enabling
18
- // multiple OAuth apps per email (different quotas, scopes, etc.).
19
- // Default is the Thunderbird client ID (the original/only client).
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-encoded OAuth2 Credentials
26
+ tokens String // JSON: OAuth2 Credentials (google) or ImapSmtpCredentials (imap_smtp)
25
27
  createdAt DateTime
26
28
  updatedAt DateTime @updatedAt
27
29
 
@@ -1,112 +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. Use this skill whenever the user asks to check email,
7
- send messages, schedule meetings, 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.
8
17
  ---
9
18
 
10
- # zele — Gmail & Google Calendar CLI
19
+ # zele
11
20
 
12
- A multi-account Gmail and Google Calendar client. Output is YAML, pipe-friendly.
13
-
14
- ## Setup
21
+ Every time you use zele, you MUST fetch the latest README:
15
22
 
16
23
  ```bash
17
- # install (requires bun)
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
- **Remote/headless 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:
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
- # list connected accounts
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
- ## Examples
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
- # read a thread (thread IDs come from list/search output)
90
- zele mail read <threadId>
35
+ ## Rules
91
36
 
92
- # send an email with attachment
93
- zele mail send --to alice@example.com --subject "Report" --body "See attached" --attach report.pdf
94
-
95
- # reply all
96
- zele mail reply <threadId> --body "Thanks!" --all
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) => ({ email: r.email, appId: r.appId }))
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 GmailClient instances for all accounts (or filtered by email list).
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<Array<{ email: string; appId: string; client: GmailClient }>> {
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 { email: account.email, appId: account.appId, client: new GmailClient({ auth, account }) }
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 GmailClient. Errors if multiple accounts exist
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<{ email: string; appId: string; client: GmailClient }> {
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 all accounts (or filtered by email list).
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
- filtered.map(async (account) => {
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 tokens: Credentials = JSON.parse(row.tokens)
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
- expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
1005
+ accountType,
1006
+ capabilities,
739
1007
  }
740
1008
  })
741
1009
  }
@@ -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
  )