zele 0.3.0 → 0.3.6

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 (157) hide show
  1. package/README.md +1 -1
  2. package/bin/zele +27 -0
  3. package/dist/api-utils.d.ts +51 -2
  4. package/dist/api-utils.js +89 -3
  5. package/dist/api-utils.js.map +1 -1
  6. package/dist/auth.d.ts +27 -6
  7. package/dist/auth.js +185 -129
  8. package/dist/auth.js.map +1 -1
  9. package/dist/calendar-client.d.ts +16 -9
  10. package/dist/calendar-client.js +163 -59
  11. package/dist/calendar-client.js.map +1 -1
  12. package/dist/cli.js +34 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/attachment.js +17 -15
  15. package/dist/commands/attachment.js.map +1 -1
  16. package/dist/commands/auth-cmd.js +20 -9
  17. package/dist/commands/auth-cmd.js.map +1 -1
  18. package/dist/commands/calendar.js +67 -78
  19. package/dist/commands/calendar.js.map +1 -1
  20. package/dist/commands/draft.js +25 -18
  21. package/dist/commands/draft.js.map +1 -1
  22. package/dist/commands/label.js +33 -45
  23. package/dist/commands/label.js.map +1 -1
  24. package/dist/commands/mail-actions.js +11 -13
  25. package/dist/commands/mail-actions.js.map +1 -1
  26. package/dist/commands/mail.js +119 -127
  27. package/dist/commands/mail.js.map +1 -1
  28. package/dist/commands/profile.js +18 -21
  29. package/dist/commands/profile.js.map +1 -1
  30. package/dist/commands/watch.js +33 -261
  31. package/dist/commands/watch.js.map +1 -1
  32. package/dist/db.js +12 -13
  33. package/dist/db.js.map +1 -1
  34. package/dist/generated/browser.d.ts +12 -27
  35. package/dist/generated/client.d.ts +13 -28
  36. package/dist/generated/client.js +1 -1
  37. package/dist/generated/commonInputTypes.d.ts +90 -26
  38. package/dist/generated/enums.d.ts +0 -4
  39. package/dist/generated/enums.js +0 -3
  40. package/dist/generated/enums.js.map +1 -1
  41. package/dist/generated/internal/class.d.ts +22 -55
  42. package/dist/generated/internal/class.js +12 -4
  43. package/dist/generated/internal/class.js.map +1 -1
  44. package/dist/generated/internal/prismaNamespace.d.ts +272 -511
  45. package/dist/generated/internal/prismaNamespace.js +54 -66
  46. package/dist/generated/internal/prismaNamespace.js.map +1 -1
  47. package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
  48. package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
  49. package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
  50. package/dist/generated/models/Account.d.ts +1637 -0
  51. package/dist/generated/models/Account.js +2 -0
  52. package/dist/generated/models/Account.js.map +1 -0
  53. package/dist/generated/models/CalendarList.d.ts +1161 -0
  54. package/dist/generated/models/CalendarList.js +2 -0
  55. package/dist/generated/models/CalendarList.js.map +1 -0
  56. package/dist/generated/models/Label.d.ts +1161 -0
  57. package/dist/generated/models/Label.js +2 -0
  58. package/dist/generated/models/Label.js.map +1 -0
  59. package/dist/generated/models/Profile.d.ts +1269 -0
  60. package/dist/generated/models/Profile.js +2 -0
  61. package/dist/generated/models/Profile.js.map +1 -0
  62. package/dist/generated/models/SyncState.d.ts +1130 -0
  63. package/dist/generated/models/SyncState.js +2 -0
  64. package/dist/generated/models/SyncState.js.map +1 -0
  65. package/dist/generated/models/Thread.d.ts +1608 -0
  66. package/dist/generated/models/Thread.js +2 -0
  67. package/dist/generated/models/Thread.js.map +1 -0
  68. package/dist/generated/models.d.ts +6 -9
  69. package/dist/gmail-client.d.ts +119 -94
  70. package/dist/gmail-client.js +862 -322
  71. package/dist/gmail-client.js.map +1 -1
  72. package/dist/mail-tui.d.ts +1 -0
  73. package/dist/mail-tui.js +517 -0
  74. package/dist/mail-tui.js.map +1 -0
  75. package/dist/output.d.ts +6 -0
  76. package/dist/output.js +124 -11
  77. package/dist/output.js.map +1 -1
  78. package/package.json +39 -11
  79. package/schema.prisma +81 -113
  80. package/src/api-utils.ts +103 -5
  81. package/src/auth.ts +224 -143
  82. package/src/calendar-client.ts +196 -89
  83. package/src/cli.ts +39 -1
  84. package/src/commands/attachment.ts +18 -19
  85. package/src/commands/auth-cmd.ts +19 -9
  86. package/src/commands/calendar.ts +42 -85
  87. package/src/commands/draft.ts +19 -22
  88. package/src/commands/label.ts +21 -57
  89. package/src/commands/mail-actions.ts +11 -19
  90. package/src/commands/mail.ts +109 -148
  91. package/src/commands/profile.ts +12 -28
  92. package/src/commands/watch.ts +37 -304
  93. package/src/db.ts +13 -16
  94. package/src/generated/browser.ts +49 -0
  95. package/src/generated/client.ts +71 -0
  96. package/src/generated/commonInputTypes.ts +332 -0
  97. package/src/generated/enums.ts +17 -0
  98. package/src/generated/internal/class.ts +250 -0
  99. package/src/generated/internal/prismaNamespace.ts +1198 -0
  100. package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
  101. package/src/generated/models/Account.ts +1848 -0
  102. package/src/generated/models/CalendarList.ts +1331 -0
  103. package/src/generated/models/Label.ts +1331 -0
  104. package/src/generated/models/Profile.ts +1439 -0
  105. package/src/generated/models/SyncState.ts +1300 -0
  106. package/src/generated/models/Thread.ts +1787 -0
  107. package/src/generated/models.ts +17 -0
  108. package/src/gmail-client.test.ts +59 -0
  109. package/src/gmail-client.ts +1034 -429
  110. package/src/mail-tui.tsx +1061 -0
  111. package/src/output.test.ts +1093 -0
  112. package/src/output.ts +128 -13
  113. package/src/schema.sql +58 -68
  114. package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
  115. package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
  116. package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
  117. package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
  118. package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
  119. package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
  120. package/AGENTS.md +0 -26
  121. package/CHANGELOG.md +0 -43
  122. package/dist/generated/models/accounts.d.ts +0 -2000
  123. package/dist/generated/models/accounts.js +0 -2
  124. package/dist/generated/models/accounts.js.map +0 -1
  125. package/dist/generated/models/calendar_events.d.ts +0 -1433
  126. package/dist/generated/models/calendar_events.js +0 -2
  127. package/dist/generated/models/calendar_events.js.map +0 -1
  128. package/dist/generated/models/calendar_lists.d.ts +0 -1131
  129. package/dist/generated/models/calendar_lists.js +0 -2
  130. package/dist/generated/models/calendar_lists.js.map +0 -1
  131. package/dist/generated/models/label_counts.d.ts +0 -1131
  132. package/dist/generated/models/label_counts.js +0 -2
  133. package/dist/generated/models/label_counts.js.map +0 -1
  134. package/dist/generated/models/labels.d.ts +0 -1131
  135. package/dist/generated/models/labels.js +0 -2
  136. package/dist/generated/models/labels.js.map +0 -1
  137. package/dist/generated/models/profiles.d.ts +0 -1131
  138. package/dist/generated/models/profiles.js +0 -2
  139. package/dist/generated/models/profiles.js.map +0 -1
  140. package/dist/generated/models/sync_states.d.ts +0 -1107
  141. package/dist/generated/models/sync_states.js +0 -2
  142. package/dist/generated/models/sync_states.js.map +0 -1
  143. package/dist/generated/models/thread_lists.d.ts +0 -1404
  144. package/dist/generated/models/thread_lists.js +0 -2
  145. package/dist/generated/models/thread_lists.js.map +0 -1
  146. package/dist/generated/models/threads.d.ts +0 -1247
  147. package/dist/generated/models/threads.js +0 -2
  148. package/dist/generated/models/threads.js.map +0 -1
  149. package/dist/gmail-cache.d.ts +0 -60
  150. package/dist/gmail-cache.js +0 -264
  151. package/dist/gmail-cache.js.map +0 -1
  152. package/docs/gogcli-gmail-implementation.md +0 -599
  153. package/scripts/test-device-code-clients.ts +0 -186
  154. package/scripts/test-micropython-scopes.ts +0 -72
  155. package/scripts/test-oauth-clients.ts +0 -257
  156. package/src/gmail-cache.ts +0 -339
  157. package/tsconfig.json +0 -16
package/src/auth.ts CHANGED
@@ -1,29 +1,22 @@
1
1
  // OAuth2 authentication module for zele.
2
2
  // Multi-account support: tokens are stored in the Prisma-managed SQLite DB
3
- // (accounts table) keyed by email. Supports login (browser OAuth), per-account
4
- // token refresh, and helpers to get authenticated GmailClient instances for
5
- // one or all accounts.
6
- // Migration: on first use, if legacy ~/.zele/tokens.json exists, it is
7
- // imported into the DB and renamed to tokens.json.bak.
3
+ // (accounts table) keyed by (email, app_id). Supports login (browser OAuth),
4
+ // per-account token refresh, and helpers to get authenticated GmailClient
5
+ // instances for one or all accounts.
6
+ // app_id is the Google OAuth client ID used during login, enabling future
7
+ // support for multiple OAuth apps per email.
8
8
 
9
9
  import http from 'node:http'
10
10
  import readline from 'node:readline'
11
- import fs from 'node:fs'
12
- import path from 'node:path'
13
- import os from 'node:os'
11
+ import { spawn } from 'node:child_process'
14
12
  import { OAuth2Client, type Credentials } from 'google-auth-library'
15
13
  import fkill from 'fkill'
16
14
  import pc from 'picocolors'
17
15
  import { getPrisma } from './db.js'
18
16
  import { GmailClient } from './gmail-client.js'
19
17
  import { CalendarClient } from './calendar-client.js'
20
-
21
- // ---------------------------------------------------------------------------
22
- // Config
23
- // ---------------------------------------------------------------------------
24
-
25
- const ZELE_DIR = path.join(os.homedir(), '.zele')
26
- const LEGACY_TOKENS_FILE = path.join(ZELE_DIR, 'tokens.json')
18
+ import * as errore from 'errore'
19
+ import { AuthError } from './api-utils.js'
27
20
 
28
21
  // ---------------------------------------------------------------------------
29
22
  // Known open-source Google OAuth clients (Desktop app type).
@@ -33,28 +26,31 @@ const LEGACY_TOKENS_FILE = path.join(ZELE_DIR, 'tokens.json')
33
26
  // which Google restricts — Gmail scopes are blocked from device code entirely).
34
27
  // Source: public open-source repos, tested 2026-02-09.
35
28
  // ---------------------------------------------------------------------------
36
- const OAUTH_CLIENTS = {
29
+ const OAUTH_CLIENTS: Record<string, { clientId: string; clientSecret: string; redirectPort: number }> = {
37
30
  // Mozilla Thunderbird — largest user base, highest Google quota.
38
31
  // Source: searchfox.org/comm-central/source/mailnews/base/src/OAuth2Providers.sys.mjs
39
32
  thunderbird: {
40
33
  clientId: '406964657835-aq8lmia8j95dhl1a2bvharmfk3t1hgqj.apps.googleusercontent.com',
41
34
  clientSecret: 'kSmqreRr0qwBWJgbf5Y-PjSU',
35
+ redirectPort: 8089,
42
36
  },
43
37
  // GNOME Online Accounts — used by Evolution, GNOME Calendar, Nautilus (Drive).
44
38
  // Source: github.com/GNOME/gnome-online-accounts/blob/master/meson_options.txt
45
39
  gnome: {
46
40
  clientId: '44438659992-7kgjeitenc16ssihbtdjbgguch7ju55s.apps.googleusercontent.com',
47
41
  clientSecret: '-gMLuQyDiI0XrQS_vx_mhuYF',
42
+ redirectPort: 8089,
48
43
  },
49
44
  // KDE KAccounts — used by KMail, KOrganizer, Kontact.
50
45
  // Source: github.com/KDE/kaccounts-providers google.provider.in
51
46
  kde: {
52
47
  clientId: '317066460457-pkpkedrvt2ldq6g2hj1egfka2n7vpuoo.apps.googleusercontent.com',
53
48
  clientSecret: 'Y8eFAaWfcanV3amZdDvtbYUq',
49
+ redirectPort: 8089,
54
50
  },
55
- } as const
51
+ }
56
52
 
57
- const ACTIVE_CLIENT = OAUTH_CLIENTS.thunderbird
53
+ const ACTIVE_CLIENT = OAUTH_CLIENTS.thunderbird!
58
54
 
59
55
  const CLIENT_ID =
60
56
  process.env.ZELE_CLIENT_ID ?? ACTIVE_CLIENT.clientId
@@ -62,7 +58,6 @@ const CLIENT_ID =
62
58
  const CLIENT_SECRET =
63
59
  process.env.ZELE_CLIENT_SECRET ?? ACTIVE_CLIENT.clientSecret
64
60
 
65
- const REDIRECT_PORT = 8089
66
61
  const SCOPES = [
67
62
  'https://mail.google.com/', // Gmail (full)
68
63
  'https://www.googleapis.com/auth/calendar', // Calendar (full)
@@ -70,64 +65,64 @@ const SCOPES = [
70
65
  ]
71
66
 
72
67
  // ---------------------------------------------------------------------------
73
- // OAuth2 client factory
68
+ // OAuth client resolution
74
69
  // ---------------------------------------------------------------------------
75
70
 
76
- export function createOAuth2Client(): OAuth2Client {
77
- return new OAuth2Client({
78
- clientId: CLIENT_ID,
79
- clientSecret: CLIENT_SECRET,
80
- redirectUri: `http://localhost:${REDIRECT_PORT}`,
81
- })
71
+ /**
72
+ * Resolve OAuth credentials and redirect port for a given appId.
73
+ * Looks up the matching entry in OAUTH_CLIENTS by client ID.
74
+ * Falls back to the active client / env vars.
75
+ */
76
+ function resolveOAuthClient(appId?: string) {
77
+ let clientId = CLIENT_ID
78
+ let clientSecret = CLIENT_SECRET
79
+ let redirectPort = ACTIVE_CLIENT.redirectPort
80
+
81
+ if (appId) {
82
+ // Look up by client ID value in OAUTH_CLIENTS
83
+ const entry = Object.values(OAUTH_CLIENTS).find((c) => c.clientId === appId)
84
+ if (entry) {
85
+ clientId = entry.clientId
86
+ clientSecret = entry.clientSecret
87
+ redirectPort = entry.redirectPort
88
+ } else {
89
+ // Unknown app ID — use it directly (custom client scenario).
90
+ // The caller must have set ZELE_CLIENT_SECRET or the token must
91
+ // already have a refresh_token that works without the secret.
92
+ clientId = appId
93
+ }
94
+ }
95
+
96
+ return { clientId, clientSecret, redirectPort }
82
97
  }
83
98
 
84
99
  // ---------------------------------------------------------------------------
85
- // Legacy migration: tokens.json → DB
100
+ // OAuth2 client factory
86
101
  // ---------------------------------------------------------------------------
87
102
 
88
- async function migrateLegacyTokens(): Promise<void> {
89
- if (!fs.existsSync(LEGACY_TOKENS_FILE)) return
90
-
91
- const prisma = await getPrisma()
92
- const count = await prisma.accounts.count()
93
- if (count > 0) {
94
- // DB already has accounts skip migration
95
- return
96
- }
97
-
98
- try {
99
- const data = fs.readFileSync(LEGACY_TOKENS_FILE, 'utf-8')
100
- const tokens: Credentials = JSON.parse(data)
101
-
102
- // We need to discover the email for this token
103
- const oauth2Client = createOAuth2Client()
104
- oauth2Client.setCredentials(tokens)
105
-
106
- // Refresh if expired
107
- if (tokens.expiry_date && tokens.expiry_date < Date.now()) {
108
- const { credentials } = await oauth2Client.refreshAccessToken()
109
- oauth2Client.setCredentials(credentials)
110
- Object.assign(tokens, credentials)
111
- }
103
+ /**
104
+ * Create an OAuth2Client. If appId is provided, looks up the matching
105
+ * client credentials from OAUTH_CLIENTS by client ID. Falls back to
106
+ * the active client / env vars.
107
+ */
108
+ export function createOAuth2Client(appId?: string): OAuth2Client {
109
+ const { clientId, clientSecret, redirectPort } = resolveOAuthClient(appId)
112
110
 
113
- const client = new GmailClient({ auth: oauth2Client })
114
- const profile = await client.getProfile()
115
- const email = profile.emailAddress
111
+ return new OAuth2Client({
112
+ clientId,
113
+ clientSecret,
114
+ redirectUri: `http://localhost:${redirectPort}`,
115
+ })
116
+ }
116
117
 
117
- await prisma.accounts.create({
118
- data: {
119
- email,
120
- tokens: JSON.stringify(tokens),
121
- updated_at: new Date(),
122
- },
123
- })
118
+ // ---------------------------------------------------------------------------
119
+ // Account identifier — used throughout the codebase to scope data
120
+ // to a specific (email, app_id) pair.
121
+ // ---------------------------------------------------------------------------
124
122
 
125
- // Rename old file so we don't migrate again
126
- fs.renameSync(LEGACY_TOKENS_FILE, LEGACY_TOKENS_FILE + '.bak')
127
- process.stderr.write(pc.green(`Migrated legacy tokens for ${email}`) + '\n')
128
- } catch (err) {
129
- process.stderr.write(pc.yellow(`Warning: legacy token migration failed: ${err}`) + '\n')
130
- }
123
+ export interface AccountId {
124
+ email: string
125
+ appId: string
131
126
  }
132
127
 
133
128
  // ---------------------------------------------------------------------------
@@ -138,12 +133,10 @@ function extractCodeFromInput(input: string): string | null {
138
133
  const trimmed = input.trim()
139
134
  if (!trimmed) return null
140
135
 
141
- try {
142
- const url = new URL(trimmed)
136
+ const url = errore.tryFn(() => new URL(trimmed))
137
+ if (!(url instanceof Error)) {
143
138
  const code = url.searchParams.get('code')
144
139
  if (code) return code
145
- } catch {
146
- // Not a URL
147
140
  }
148
141
 
149
142
  if (trimmed.length > 10 && !trimmed.includes(' ')) {
@@ -153,30 +146,84 @@ function extractCodeFromInput(input: string): string | null {
153
146
  return null
154
147
  }
155
148
 
156
- async function getAuthCodeFromBrowser(oauth2Client: OAuth2Client): Promise<string> {
149
+ interface BrowserAuthOptions {
150
+ openBrowser?: boolean
151
+ allowManualCodeEntry?: boolean
152
+ showInstructions?: boolean
153
+ }
154
+
155
+ function openUrlInBrowser(url: string): Error | void {
156
+ const command = process.platform === 'darwin'
157
+ ? { bin: 'open', args: [url] }
158
+ : process.platform === 'win32'
159
+ ? { bin: 'cmd', args: ['/c', 'start', '', url] }
160
+ : { bin: 'xdg-open', args: [url] }
161
+
162
+ const child = errore.tryFn(() =>
163
+ spawn(command.bin, command.args, {
164
+ detached: true,
165
+ stdio: 'ignore',
166
+ }),
167
+ )
168
+ if (child instanceof Error) {
169
+ return new Error(`Failed to open browser with ${command.bin}`, { cause: child })
170
+ }
171
+
172
+ child.unref()
173
+ }
174
+
175
+ async function getAuthCodeFromBrowser(
176
+ oauth2Client: OAuth2Client,
177
+ port: number,
178
+ options?: BrowserAuthOptions,
179
+ ): Promise<string | Error> {
180
+ const openBrowser = options?.openBrowser ?? true
181
+ const allowManualCodeEntry = options?.allowManualCodeEntry ?? true
182
+ const showInstructions = options?.showInstructions ?? true
183
+
157
184
  const authUrl = oauth2Client.generateAuthUrl({
158
185
  access_type: 'offline',
159
186
  scope: SCOPES,
160
187
  prompt: 'consent',
161
188
  })
162
189
 
163
- await fkill(`:${REDIRECT_PORT}`, { force: true, silent: true }).catch(() => {})
190
+ await errore.tryAsync({
191
+ try: () => fkill(`:${port}`, { force: true, silent: true }),
192
+ catch: (err) => new Error(String(err), { cause: err }),
193
+ })
164
194
 
165
- process.stderr.write('\n' + pc.bold('1.') + ' Open this URL to authorize:\n\n')
166
- process.stderr.write(' ' + pc.cyan(pc.underline(authUrl)) + '\n\n')
167
- process.stderr.write(pc.bold('2.') + ' If running locally, the browser will redirect automatically.\n')
168
- process.stderr.write(pc.dim(' If running remotely, the redirect page won\'t load — that\'s fine.') + '\n')
169
- process.stderr.write(pc.dim(' Just copy the URL from your browser\'s address bar and paste it below.') + '\n\n')
195
+ if (showInstructions) {
196
+ console.error('\n' + pc.bold('1.') + ' Open this URL to authorize:\n')
197
+ console.error(' ' + pc.cyan(pc.underline(authUrl)) + '\n')
198
+ console.error(pc.bold('2.') + ' If running locally, the browser will redirect automatically.')
199
+ console.error(pc.dim(' If running remotely, the redirect page won\'t load that\'s fine.'))
200
+ console.error(pc.dim(' Just copy the URL from your browser\'s address bar and paste it below.') + '\n')
201
+ }
170
202
 
171
- return new Promise((resolve, reject) => {
203
+ if (openBrowser) {
204
+ const openResult = openUrlInBrowser(authUrl)
205
+ if (openResult instanceof Error && showInstructions) {
206
+ console.error(pc.yellow(`Could not auto-open browser: ${openResult.message}`))
207
+ console.error(pc.dim('Open the URL above manually.'))
208
+ }
209
+ }
210
+
211
+ return new Promise((resolve) => {
172
212
  let resolved = false
173
213
  let server: http.Server | null = null
174
214
  let rl: readline.Interface | null = null
175
215
 
216
+ function closeServer() {
217
+ if (server) {
218
+ server.closeAllConnections()
219
+ server.close()
220
+ }
221
+ }
222
+
176
223
  function finish(code: string) {
177
224
  if (resolved) return
178
225
  resolved = true
179
- server?.close()
226
+ closeServer()
180
227
  if (rl) {
181
228
  rl.close()
182
229
  process.stdin.unref()
@@ -187,13 +234,13 @@ async function getAuthCodeFromBrowser(oauth2Client: OAuth2Client): Promise<strin
187
234
  function fail(err: Error) {
188
235
  if (resolved) return
189
236
  resolved = true
190
- server?.close()
237
+ closeServer()
191
238
  rl?.close()
192
- reject(err)
239
+ resolve(err)
193
240
  }
194
241
 
195
242
  server = http.createServer((req, res) => {
196
- const url = new URL(req.url!, `http://localhost:${REDIRECT_PORT}`)
243
+ const url = new URL(req.url!, `http://localhost:${port}`)
197
244
  const code = url.searchParams.get('code')
198
245
  const error = url.searchParams.get('error')
199
246
 
@@ -215,17 +262,20 @@ async function getAuthCodeFromBrowser(oauth2Client: OAuth2Client): Promise<strin
215
262
  res.end('<h1>No authorization code received</h1>')
216
263
  })
217
264
 
218
- server.listen(REDIRECT_PORT)
265
+ server.listen(port)
266
+ server.on('error', (err) => {
267
+ fail(new Error(`Failed to start local auth callback server on port ${port}`, { cause: err }))
268
+ })
219
269
 
220
- if (process.stdin.isTTY) {
270
+ if (allowManualCodeEntry && process.stdin.isTTY) {
221
271
  rl = readline.createInterface({ input: process.stdin, output: process.stderr })
222
272
  rl.question(pc.dim('Paste redirect URL here (or wait for auto-redirect): '), (answer) => {
223
273
  const code = extractCodeFromInput(answer)
224
274
  if (code) {
225
275
  finish(code)
226
276
  } else {
227
- process.stderr.write(pc.yellow('Could not extract authorization code from input.') + '\n')
228
- process.stderr.write(pc.dim('Waiting for browser redirect...') + '\n')
277
+ console.error(pc.yellow('Could not extract authorization code from input.'))
278
+ console.error(pc.dim('Waiting for browser redirect...'))
229
279
  }
230
280
  })
231
281
  }
@@ -238,51 +288,82 @@ async function getAuthCodeFromBrowser(oauth2Client: OAuth2Client): Promise<strin
238
288
 
239
289
  /**
240
290
  * Run the full browser OAuth flow and save the account to the DB.
241
- * Returns the email and an authenticated GmailClient.
291
+ * Returns either a successful login payload or an Error value.
242
292
  */
243
- export async function login(): Promise<{ email: string; client: GmailClient }> {
244
- const oauth2Client = createOAuth2Client()
293
+ export async function login(
294
+ appId?: string,
295
+ options?: BrowserAuthOptions,
296
+ ): Promise<{ email: string; appId: string; client: GmailClient } | Error> {
297
+ const resolved = resolveOAuthClient(appId)
298
+ const oauth2Client = createOAuth2Client(appId)
299
+
300
+ const code = await getAuthCodeFromBrowser(oauth2Client, resolved.redirectPort, options)
301
+ if (code instanceof Error) return code
302
+
303
+ if (options?.showInstructions ?? true) {
304
+ console.error(pc.dim('Got authorization code, exchanging for tokens...'))
305
+ }
245
306
 
246
- const code = await getAuthCodeFromBrowser(oauth2Client)
247
- process.stderr.write(pc.dim('Got authorization code, exchanging for tokens...') + '\n')
307
+ const tokenResponse = await errore.tryAsync({
308
+ try: () => oauth2Client.getToken(code),
309
+ catch: (err) => new Error('Failed to exchange authorization code for tokens', { cause: err }),
310
+ })
311
+ if (tokenResponse instanceof Error) return tokenResponse
248
312
 
249
- const { tokens } = await oauth2Client.getToken(code)
313
+ const { tokens } = tokenResponse
250
314
  oauth2Client.setCredentials(tokens)
251
315
 
252
316
  // Discover email
253
317
  const client = new GmailClient({ auth: oauth2Client })
254
318
  const profile = await client.getProfile()
319
+ if (profile instanceof Error) return profile
255
320
  const email = profile.emailAddress
256
321
 
257
322
  // Upsert account in DB
258
323
  const prisma = await getPrisma()
259
- await prisma.accounts.upsert({
260
- where: { email },
261
- create: { email, tokens: JSON.stringify(tokens), updated_at: new Date() },
262
- update: { tokens: JSON.stringify(tokens), updated_at: new Date() },
324
+ const upsertResult = await errore.tryAsync({
325
+ try: () =>
326
+ prisma.account.upsert({
327
+ where: { email_appId: { email, appId: resolved.clientId } },
328
+ create: {
329
+ email,
330
+ appId: resolved.clientId,
331
+ accountStatus: 'active',
332
+ tokens: JSON.stringify(tokens),
333
+ createdAt: new Date(),
334
+ updatedAt: new Date(),
335
+ },
336
+ update: { tokens: JSON.stringify(tokens), updatedAt: new Date() },
337
+ }),
338
+ catch: (err) => new Error(`Failed to save account ${email}`, { cause: err }),
263
339
  })
340
+ if (upsertResult instanceof Error) return upsertResult
264
341
 
265
- return { email, client }
342
+ return { email, appId: resolved.clientId, client }
266
343
  }
267
344
 
268
345
  // ---------------------------------------------------------------------------
269
346
  // Logout: remove account from DB
270
347
  // ---------------------------------------------------------------------------
271
348
 
272
- export async function logout(email: string): Promise<void> {
349
+ export async function logout(email: string): Promise<void | Error> {
273
350
  const prisma = await getPrisma()
274
- await prisma.accounts.delete({ where: { email } })
351
+ // Delete all app_id entries for this email (logout removes all credentials for the email)
352
+ const result = await errore.tryAsync({
353
+ try: () => prisma.account.deleteMany({ where: { email } }),
354
+ catch: (err) => new Error(`Failed to remove credentials for ${email}`, { cause: err }),
355
+ })
356
+ if (result instanceof Error) return result
275
357
  }
276
358
 
277
359
  // ---------------------------------------------------------------------------
278
360
  // Account listing
279
361
  // ---------------------------------------------------------------------------
280
362
 
281
- export async function listAccounts(): Promise<string[]> {
282
- await migrateLegacyTokens()
363
+ export async function listAccounts(): Promise<AccountId[]> {
283
364
  const prisma = await getPrisma()
284
- const rows = await prisma.accounts.findMany({ select: { email: true } })
285
- return rows.map((r) => r.email)
365
+ const rows = await prisma.account.findMany({ select: { email: true, appId: true } })
366
+ return rows.map((r) => ({ email: r.email, appId: r.appId }))
286
367
  }
287
368
 
288
369
  // ---------------------------------------------------------------------------
@@ -292,28 +373,31 @@ export async function listAccounts(): Promise<string[]> {
292
373
  /**
293
374
  * Create an authenticated OAuth2Client for a known account.
294
375
  * Loads tokens from DB, refreshes if expired, saves refreshed tokens back.
376
+ * Uses the stored app_id to create the OAuth2 client with the correct credentials.
295
377
  */
296
- async function authenticateAccount(email: string): Promise<OAuth2Client> {
378
+ async function authenticateAccount(account: AccountId): Promise<OAuth2Client> {
297
379
  const prisma = await getPrisma()
298
- const row = await prisma.accounts.findUnique({ where: { email } })
380
+ const row = await prisma.account.findUnique({
381
+ where: { email_appId: { email: account.email, appId: account.appId } },
382
+ })
299
383
  if (!row) {
300
- throw new Error(`No account found for ${email}. Run: zele login`)
384
+ throw new Error(`No account found for ${account.email}. Run: zele login`)
301
385
  }
302
386
 
303
387
  const tokens: Credentials = JSON.parse(row.tokens)
304
- const oauth2Client = createOAuth2Client()
388
+ const oauth2Client = createOAuth2Client(account.appId)
305
389
  oauth2Client.setCredentials(tokens)
306
390
 
307
391
  // Refresh if expired — merge to preserve refresh_token which Google
308
392
  // often omits from refresh responses
309
393
  if (tokens.expiry_date && tokens.expiry_date < Date.now()) {
310
- process.stderr.write(pc.dim(`Token expired for ${email}, refreshing...`) + '\n')
394
+ console.error(pc.dim(`Token expired for ${account.email}, refreshing...`))
311
395
  const { credentials } = await oauth2Client.refreshAccessToken()
312
396
  const merged = { ...tokens, ...credentials }
313
397
  oauth2Client.setCredentials(merged)
314
- await prisma.accounts.update({
315
- where: { email },
316
- data: { tokens: JSON.stringify(merged), updated_at: new Date() },
398
+ await prisma.account.update({
399
+ where: { email_appId: { email: account.email, appId: account.appId } },
400
+ data: { tokens: JSON.stringify(merged), updatedAt: new Date() },
317
401
  })
318
402
  }
319
403
 
@@ -326,27 +410,25 @@ async function authenticateAccount(email: string): Promise<OAuth2Client> {
326
410
  */
327
411
  export async function getClients(
328
412
  accounts?: string[],
329
- ): Promise<Array<{ email: string; client: GmailClient }>> {
330
- await migrateLegacyTokens()
331
-
332
- const allEmails = await listAccounts()
333
- if (allEmails.length === 0) {
413
+ ): Promise<Array<{ email: string; appId: string; client: GmailClient }>> {
414
+ const allAccounts = await listAccounts()
415
+ if (allAccounts.length === 0) {
334
416
  throw new Error('No accounts registered. Run: zele login')
335
417
  }
336
418
 
337
- const emails = accounts && accounts.length > 0
338
- ? allEmails.filter((e) => accounts.includes(e))
339
- : allEmails
419
+ const filtered = accounts && accounts.length > 0
420
+ ? allAccounts.filter((a) => accounts.includes(a.email))
421
+ : allAccounts
340
422
 
341
- if (emails.length === 0) {
342
- const available = allEmails.join(', ')
423
+ if (filtered.length === 0) {
424
+ const available = allAccounts.map((a) => a.email).join(', ')
343
425
  throw new Error(`No matching accounts. Available: ${available}`)
344
426
  }
345
427
 
346
428
  const results = await Promise.all(
347
- emails.map(async (email) => {
348
- const auth = await authenticateAccount(email)
349
- return { email, client: new GmailClient({ auth }) }
429
+ filtered.map(async (account) => {
430
+ const auth = await authenticateAccount(account)
431
+ return { email: account.email, appId: account.appId, client: new GmailClient({ auth, account }) }
350
432
  }),
351
433
  )
352
434
 
@@ -359,7 +441,7 @@ export async function getClients(
359
441
  */
360
442
  export async function getClient(
361
443
  accounts?: string[],
362
- ): Promise<{ email: string; client: GmailClient }> {
444
+ ): Promise<{ email: string; appId: string; client: GmailClient }> {
363
445
  const clients = await getClients(accounts)
364
446
  if (clients.length === 1) {
365
447
  return clients[0]!
@@ -380,29 +462,27 @@ export async function getClient(
380
462
  */
381
463
  export async function getCalendarClients(
382
464
  accounts?: string[],
383
- ): Promise<Array<{ email: string; client: CalendarClient }>> {
384
- await migrateLegacyTokens()
385
-
386
- const allEmails = await listAccounts()
387
- if (allEmails.length === 0) {
465
+ ): Promise<Array<{ email: string; appId: string; client: CalendarClient }>> {
466
+ const allAccounts = await listAccounts()
467
+ if (allAccounts.length === 0) {
388
468
  throw new Error('No accounts registered. Run: zele login')
389
469
  }
390
470
 
391
- const emails = accounts && accounts.length > 0
392
- ? allEmails.filter((e) => accounts.includes(e))
393
- : allEmails
471
+ const filtered = accounts && accounts.length > 0
472
+ ? allAccounts.filter((a) => accounts.includes(a.email))
473
+ : allAccounts
394
474
 
395
- if (emails.length === 0) {
396
- const available = allEmails.join(', ')
475
+ if (filtered.length === 0) {
476
+ const available = allAccounts.map((a) => a.email).join(', ')
397
477
  throw new Error(`No matching accounts. Available: ${available}`)
398
478
  }
399
479
 
400
480
  const results = await Promise.all(
401
- emails.map(async (email) => {
402
- const auth = await authenticateAccount(email)
481
+ filtered.map(async (account) => {
482
+ const auth = await authenticateAccount(account)
403
483
  const { token } = await auth.getAccessToken()
404
- if (!token) throw new Error(`Failed to get access token for ${email}`)
405
- return { email, client: new CalendarClient({ accessToken: token, email }) }
484
+ if (!token) throw new Error(`Failed to get access token for ${account.email}`)
485
+ return { email: account.email, appId: account.appId, client: new CalendarClient({ accessToken: token, email: account.email, appId: account.appId }) }
406
486
  }),
407
487
  )
408
488
 
@@ -415,7 +495,7 @@ export async function getCalendarClients(
415
495
  */
416
496
  export async function getCalendarClient(
417
497
  accounts?: string[],
418
- ): Promise<{ email: string; client: CalendarClient }> {
498
+ ): Promise<{ email: string; appId: string; client: CalendarClient }> {
419
499
  const clients = await getCalendarClients(accounts)
420
500
  if (clients.length === 1) {
421
501
  return clients[0]!
@@ -433,18 +513,19 @@ export async function getCalendarClient(
433
513
 
434
514
  export interface AuthStatus {
435
515
  email: string
516
+ appId: string
436
517
  expiresAt?: Date
437
518
  }
438
519
 
439
520
  export async function getAuthStatuses(): Promise<AuthStatus[]> {
440
- await migrateLegacyTokens()
441
521
  const prisma = await getPrisma()
442
- const rows = await prisma.accounts.findMany()
522
+ const rows = await prisma.account.findMany()
443
523
 
444
524
  return rows.map((row) => {
445
525
  const tokens: Credentials = JSON.parse(row.tokens)
446
526
  return {
447
527
  email: row.email,
528
+ appId: row.appId,
448
529
  expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : undefined,
449
530
  }
450
531
  })