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.
- package/README.md +1 -1
- package/bin/zele +27 -0
- package/dist/api-utils.d.ts +51 -2
- package/dist/api-utils.js +89 -3
- package/dist/api-utils.js.map +1 -1
- package/dist/auth.d.ts +27 -6
- package/dist/auth.js +185 -129
- package/dist/auth.js.map +1 -1
- package/dist/calendar-client.d.ts +16 -9
- package/dist/calendar-client.js +163 -59
- package/dist/calendar-client.js.map +1 -1
- package/dist/cli.js +34 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/attachment.js +17 -15
- package/dist/commands/attachment.js.map +1 -1
- package/dist/commands/auth-cmd.js +20 -9
- package/dist/commands/auth-cmd.js.map +1 -1
- package/dist/commands/calendar.js +67 -78
- package/dist/commands/calendar.js.map +1 -1
- package/dist/commands/draft.js +25 -18
- package/dist/commands/draft.js.map +1 -1
- package/dist/commands/label.js +33 -45
- package/dist/commands/label.js.map +1 -1
- package/dist/commands/mail-actions.js +11 -13
- package/dist/commands/mail-actions.js.map +1 -1
- package/dist/commands/mail.js +119 -127
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/profile.js +18 -21
- package/dist/commands/profile.js.map +1 -1
- package/dist/commands/watch.js +33 -261
- package/dist/commands/watch.js.map +1 -1
- package/dist/db.js +12 -13
- package/dist/db.js.map +1 -1
- package/dist/generated/browser.d.ts +12 -27
- package/dist/generated/client.d.ts +13 -28
- package/dist/generated/client.js +1 -1
- package/dist/generated/commonInputTypes.d.ts +90 -26
- package/dist/generated/enums.d.ts +0 -4
- package/dist/generated/enums.js +0 -3
- package/dist/generated/enums.js.map +1 -1
- package/dist/generated/internal/class.d.ts +22 -55
- package/dist/generated/internal/class.js +12 -4
- package/dist/generated/internal/class.js.map +1 -1
- package/dist/generated/internal/prismaNamespace.d.ts +272 -511
- package/dist/generated/internal/prismaNamespace.js +54 -66
- package/dist/generated/internal/prismaNamespace.js.map +1 -1
- package/dist/generated/internal/prismaNamespaceBrowser.d.ts +60 -74
- package/dist/generated/internal/prismaNamespaceBrowser.js +50 -62
- package/dist/generated/internal/prismaNamespaceBrowser.js.map +1 -1
- package/dist/generated/models/Account.d.ts +1637 -0
- package/dist/generated/models/Account.js +2 -0
- package/dist/generated/models/Account.js.map +1 -0
- package/dist/generated/models/CalendarList.d.ts +1161 -0
- package/dist/generated/models/CalendarList.js +2 -0
- package/dist/generated/models/CalendarList.js.map +1 -0
- package/dist/generated/models/Label.d.ts +1161 -0
- package/dist/generated/models/Label.js +2 -0
- package/dist/generated/models/Label.js.map +1 -0
- package/dist/generated/models/Profile.d.ts +1269 -0
- package/dist/generated/models/Profile.js +2 -0
- package/dist/generated/models/Profile.js.map +1 -0
- package/dist/generated/models/SyncState.d.ts +1130 -0
- package/dist/generated/models/SyncState.js +2 -0
- package/dist/generated/models/SyncState.js.map +1 -0
- package/dist/generated/models/Thread.d.ts +1608 -0
- package/dist/generated/models/Thread.js +2 -0
- package/dist/generated/models/Thread.js.map +1 -0
- package/dist/generated/models.d.ts +6 -9
- package/dist/gmail-client.d.ts +119 -94
- package/dist/gmail-client.js +862 -322
- package/dist/gmail-client.js.map +1 -1
- package/dist/mail-tui.d.ts +1 -0
- package/dist/mail-tui.js +517 -0
- package/dist/mail-tui.js.map +1 -0
- package/dist/output.d.ts +6 -0
- package/dist/output.js +124 -11
- package/dist/output.js.map +1 -1
- package/package.json +39 -11
- package/schema.prisma +81 -113
- package/src/api-utils.ts +103 -5
- package/src/auth.ts +224 -143
- package/src/calendar-client.ts +196 -89
- package/src/cli.ts +39 -1
- package/src/commands/attachment.ts +18 -19
- package/src/commands/auth-cmd.ts +19 -9
- package/src/commands/calendar.ts +42 -85
- package/src/commands/draft.ts +19 -22
- package/src/commands/label.ts +21 -57
- package/src/commands/mail-actions.ts +11 -19
- package/src/commands/mail.ts +109 -148
- package/src/commands/profile.ts +12 -28
- package/src/commands/watch.ts +37 -304
- package/src/db.ts +13 -16
- package/src/generated/browser.ts +49 -0
- package/src/generated/client.ts +71 -0
- package/src/generated/commonInputTypes.ts +332 -0
- package/src/generated/enums.ts +17 -0
- package/src/generated/internal/class.ts +250 -0
- package/src/generated/internal/prismaNamespace.ts +1198 -0
- package/src/generated/internal/prismaNamespaceBrowser.ts +169 -0
- package/src/generated/models/Account.ts +1848 -0
- package/src/generated/models/CalendarList.ts +1331 -0
- package/src/generated/models/Label.ts +1331 -0
- package/src/generated/models/Profile.ts +1439 -0
- package/src/generated/models/SyncState.ts +1300 -0
- package/src/generated/models/Thread.ts +1787 -0
- package/src/generated/models.ts +17 -0
- package/src/gmail-client.test.ts +59 -0
- package/src/gmail-client.ts +1034 -429
- package/src/mail-tui.tsx +1061 -0
- package/src/output.test.ts +1093 -0
- package/src/output.ts +128 -13
- package/src/schema.sql +58 -68
- package/src/test-fixtures/email-html/safe-claude-event.html +28 -0
- package/src/test-fixtures/email-html/safe-product-announcement.html +25 -0
- package/src/test-fixtures/email-html/safe-tracked-links.html +27 -0
- package/src/test-fixtures/email-html-snapshots/safe-claude-event.html.md +9 -0
- package/src/test-fixtures/email-html-snapshots/safe-product-announcement.html.md +13 -0
- package/src/test-fixtures/email-html-snapshots/safe-tracked-links.html.md +7 -0
- package/AGENTS.md +0 -26
- package/CHANGELOG.md +0 -43
- package/dist/generated/models/accounts.d.ts +0 -2000
- package/dist/generated/models/accounts.js +0 -2
- package/dist/generated/models/accounts.js.map +0 -1
- package/dist/generated/models/calendar_events.d.ts +0 -1433
- package/dist/generated/models/calendar_events.js +0 -2
- package/dist/generated/models/calendar_events.js.map +0 -1
- package/dist/generated/models/calendar_lists.d.ts +0 -1131
- package/dist/generated/models/calendar_lists.js +0 -2
- package/dist/generated/models/calendar_lists.js.map +0 -1
- package/dist/generated/models/label_counts.d.ts +0 -1131
- package/dist/generated/models/label_counts.js +0 -2
- package/dist/generated/models/label_counts.js.map +0 -1
- package/dist/generated/models/labels.d.ts +0 -1131
- package/dist/generated/models/labels.js +0 -2
- package/dist/generated/models/labels.js.map +0 -1
- package/dist/generated/models/profiles.d.ts +0 -1131
- package/dist/generated/models/profiles.js +0 -2
- package/dist/generated/models/profiles.js.map +0 -1
- package/dist/generated/models/sync_states.d.ts +0 -1107
- package/dist/generated/models/sync_states.js +0 -2
- package/dist/generated/models/sync_states.js.map +0 -1
- package/dist/generated/models/thread_lists.d.ts +0 -1404
- package/dist/generated/models/thread_lists.js +0 -2
- package/dist/generated/models/thread_lists.js.map +0 -1
- package/dist/generated/models/threads.d.ts +0 -1247
- package/dist/generated/models/threads.js +0 -2
- package/dist/generated/models/threads.js.map +0 -1
- package/dist/gmail-cache.d.ts +0 -60
- package/dist/gmail-cache.js +0 -264
- package/dist/gmail-cache.js.map +0 -1
- package/docs/gogcli-gmail-implementation.md +0 -599
- package/scripts/test-device-code-clients.ts +0 -186
- package/scripts/test-micropython-scopes.ts +0 -72
- package/scripts/test-oauth-clients.ts +0 -257
- package/src/gmail-cache.ts +0 -339
- 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),
|
|
4
|
-
// token refresh, and helpers to get authenticated GmailClient
|
|
5
|
-
// one or all accounts.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
|
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
|
-
}
|
|
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
|
-
//
|
|
68
|
+
// OAuth client resolution
|
|
74
69
|
// ---------------------------------------------------------------------------
|
|
75
70
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
//
|
|
100
|
+
// OAuth2 client factory
|
|
86
101
|
// ---------------------------------------------------------------------------
|
|
87
102
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
return new OAuth2Client({
|
|
112
|
+
clientId,
|
|
113
|
+
clientSecret,
|
|
114
|
+
redirectUri: `http://localhost:${redirectPort}`,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
116
117
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
+
closeServer()
|
|
191
238
|
rl?.close()
|
|
192
|
-
|
|
239
|
+
resolve(err)
|
|
193
240
|
}
|
|
194
241
|
|
|
195
242
|
server = http.createServer((req, res) => {
|
|
196
|
-
const url = new URL(req.url!, `http://localhost:${
|
|
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(
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
291
|
+
* Returns either a successful login payload or an Error value.
|
|
242
292
|
*/
|
|
243
|
-
export async function login(
|
|
244
|
-
|
|
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
|
|
247
|
-
|
|
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 } =
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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<
|
|
282
|
-
await migrateLegacyTokens()
|
|
363
|
+
export async function listAccounts(): Promise<AccountId[]> {
|
|
283
364
|
const prisma = await getPrisma()
|
|
284
|
-
const rows = await prisma.
|
|
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(
|
|
378
|
+
async function authenticateAccount(account: AccountId): Promise<OAuth2Client> {
|
|
297
379
|
const prisma = await getPrisma()
|
|
298
|
-
const row = await prisma.
|
|
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
|
-
|
|
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.
|
|
315
|
-
where: { email },
|
|
316
|
-
data: { tokens: JSON.stringify(merged),
|
|
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
|
|
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
|
|
338
|
-
?
|
|
339
|
-
:
|
|
419
|
+
const filtered = accounts && accounts.length > 0
|
|
420
|
+
? allAccounts.filter((a) => accounts.includes(a.email))
|
|
421
|
+
: allAccounts
|
|
340
422
|
|
|
341
|
-
if (
|
|
342
|
-
const available =
|
|
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
|
-
|
|
348
|
-
const auth = await authenticateAccount(
|
|
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
|
|
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
|
|
392
|
-
?
|
|
393
|
-
:
|
|
471
|
+
const filtered = accounts && accounts.length > 0
|
|
472
|
+
? allAccounts.filter((a) => accounts.includes(a.email))
|
|
473
|
+
: allAccounts
|
|
394
474
|
|
|
395
|
-
if (
|
|
396
|
-
const available =
|
|
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
|
-
|
|
402
|
-
const auth = await authenticateAccount(
|
|
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.
|
|
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
|
})
|