@mcinteerj/openclaw-gmail 1.3.1 → 1.4.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mcinteerj/openclaw-gmail",
3
- "version": "1.3.1",
4
- "description": "Gmail channel plugin for OpenClaw - uses gog CLI for secure Gmail access",
3
+ "version": "1.4.0",
4
+ "description": "Gmail channel plugin for OpenClaw - direct API or gog CLI",
5
5
  "type": "module",
6
6
  "main": "index.ts",
7
7
  "author": "Jake McInteer <mcinteerj@gmail.com>",
@@ -24,11 +24,11 @@
24
24
  "channel": {
25
25
  "id": "openclaw-gmail",
26
26
  "label": "Gmail",
27
- "selectionLabel": "Gmail (gog)",
27
+ "selectionLabel": "Gmail",
28
28
  "detailLabel": "Gmail",
29
29
  "docsPath": "/channels/gmail",
30
30
  "docsLabel": "gmail",
31
- "blurb": "Uses gog for secure Gmail access.",
31
+ "blurb": "Gmail integration via direct API or gog CLI.",
32
32
  "systemImage": "envelope",
33
33
  "order": 100,
34
34
  "showConfigured": true
@@ -41,9 +41,12 @@
41
41
  "README.md"
42
42
  ],
43
43
  "dependencies": {
44
+ "@googleapis/gmail": "^16.1.1",
44
45
  "dompurify": "^3.2.3",
46
+ "google-auth-library": "^10.5.0",
45
47
  "jsdom": "^25.0.1",
46
48
  "marked": "^15.0.6",
49
+ "nodemailer": "^8.0.1",
47
50
  "proper-lockfile": "^4.1.2",
48
51
  "sanitize-html": "^2.14.0",
49
52
  "zod": "^4.3.5"
package/src/accounts.ts CHANGED
@@ -10,6 +10,12 @@ export interface ResolvedGmailAccount extends ResolvedChannelAccount {
10
10
  historyId?: string;
11
11
  delegate?: string;
12
12
  pollIntervalMs?: number;
13
+ backend?: "gog" | "api";
14
+ oauth?: {
15
+ clientId: string;
16
+ clientSecret: string;
17
+ refreshToken: string;
18
+ };
13
19
  }
14
20
 
15
21
  export function resolveGmailAccount(
@@ -42,6 +48,8 @@ export function resolveGmailAccount(
42
48
  delegate: account.delegate,
43
49
  allowFrom: account.allowFrom,
44
50
  pollIntervalMs: account.pollIntervalMs,
51
+ backend: account.backend,
52
+ oauth: account.oauth,
45
53
  };
46
54
  }
47
55
 
@@ -0,0 +1,406 @@
1
+ import { gmail as gmailApi, type gmail_v1 } from "@googleapis/gmail";
2
+ import type { OAuth2Client } from "google-auth-library";
3
+ import fs from "node:fs/promises";
4
+ import type { GmailClient } from "./gmail-client.js";
5
+ import type { ThreadResponse, GogRawMessage } from "./quoting.js";
6
+ import type { GogSearchMessage } from "./inbound.js";
7
+ import { buildMimeMessage } from "./mime.js";
8
+ import { parseEmailAddresses } from "./outbound-check.js";
9
+
10
+ /**
11
+ * Gmail API client using googleapis library directly.
12
+ * Implements GmailClient interface for the "api" backend.
13
+ */
14
+ export class ApiGmailClient implements GmailClient {
15
+ private gmail: gmail_v1.Gmail;
16
+
17
+ constructor(auth: OAuth2Client) {
18
+ this.gmail = gmailApi({ version: "v1", auth });
19
+ }
20
+
21
+ async send(opts: {
22
+ account?: string;
23
+ to?: string;
24
+ subject: string;
25
+ textBody: string;
26
+ htmlBody?: string;
27
+ threadId?: string;
28
+ replyToMessageId?: string;
29
+ replyAll?: boolean;
30
+ }): Promise<void> {
31
+ const selfEmail = opts.account || "";
32
+ let to = opts.to || "";
33
+ let cc: string | undefined;
34
+ let inReplyTo: string | undefined;
35
+ let references: string | undefined;
36
+
37
+ // For thread replies, resolve recipients and threading headers
38
+ if (opts.threadId) {
39
+ const replyCtx = await this.resolveReplyContext(
40
+ opts.threadId,
41
+ selfEmail,
42
+ opts.replyAll ?? false,
43
+ );
44
+ if (replyCtx) {
45
+ to = replyCtx.to;
46
+ cc = replyCtx.cc;
47
+ inReplyTo = replyCtx.inReplyTo;
48
+ references = replyCtx.references;
49
+ }
50
+ }
51
+
52
+ if (!to) {
53
+ throw new Error("Cannot send: no recipient resolved");
54
+ }
55
+
56
+ const mime = await buildMimeMessage({
57
+ from: selfEmail,
58
+ to,
59
+ cc,
60
+ subject: opts.subject,
61
+ text: opts.textBody,
62
+ html: opts.htmlBody,
63
+ inReplyTo,
64
+ references,
65
+ });
66
+
67
+ await this.gmail.users.messages.send({
68
+ userId: "me",
69
+ requestBody: {
70
+ raw: mime.toString("base64url"),
71
+ threadId: opts.threadId,
72
+ },
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Resolve reply recipients and threading headers from the last message in a thread.
78
+ */
79
+ private async resolveReplyContext(
80
+ threadId: string,
81
+ selfEmail: string,
82
+ replyAll: boolean,
83
+ ): Promise<{ to: string; cc?: string; inReplyTo?: string; references?: string } | null> {
84
+ const thread = await this.getThread(threadId, { full: true });
85
+ if (!thread || thread.messages.length === 0) return null;
86
+
87
+ const lastMsg = thread.messages[thread.messages.length - 1];
88
+
89
+ // Resolve To: reply to the sender of the last message
90
+ const to = lastMsg.from;
91
+
92
+ // For reply-all, Cc = everyone from To + Cc minus self and the new To
93
+ let cc: string | undefined;
94
+ if (replyAll) {
95
+ const selfLower = selfEmail.toLowerCase();
96
+ const toAddresses = parseEmailAddresses(to);
97
+ const toLower = new Set(toAddresses.map((a) => a.email.toLowerCase()));
98
+
99
+ const ccCandidates: string[] = [];
100
+ for (const field of [lastMsg.to, lastMsg.cc]) {
101
+ if (!field) continue;
102
+ const addresses = parseEmailAddresses(field);
103
+ for (const addr of addresses) {
104
+ if (addr.email.toLowerCase() !== selfLower && !toLower.has(addr.email.toLowerCase())) {
105
+ ccCandidates.push(addr.name ? `${addr.name} <${addr.email}>` : addr.email);
106
+ toLower.add(addr.email.toLowerCase()); // dedupe
107
+ }
108
+ }
109
+ }
110
+ if (ccCandidates.length > 0) {
111
+ cc = ccCandidates.join(", ");
112
+ }
113
+ }
114
+
115
+ // Resolve In-Reply-To and References from the last message
116
+ // We need the raw Message-ID header — fetch it from the API directly
117
+ let messageId: string | undefined;
118
+ try {
119
+ const res = await this.gmail.users.messages.get({
120
+ userId: "me",
121
+ id: lastMsg.id,
122
+ format: "metadata",
123
+ metadataHeaders: ["Message-ID", "References"],
124
+ });
125
+ const headers = res.data.payload?.headers || [];
126
+ messageId = headers.find((h) => h.name?.toLowerCase() === "message-id")?.value ?? undefined;
127
+ const existingRefs = headers.find((h) => h.name?.toLowerCase() === "references")?.value;
128
+ if (messageId) {
129
+ return {
130
+ to,
131
+ cc,
132
+ inReplyTo: messageId,
133
+ references: existingRefs ? `${existingRefs} ${messageId}` : messageId,
134
+ };
135
+ }
136
+ } catch {
137
+ // Fall through — send without threading headers
138
+ }
139
+
140
+ return { to, cc };
141
+ }
142
+
143
+ async getThread(threadId: string, opts?: { full?: boolean }): Promise<ThreadResponse | null> {
144
+ try {
145
+ const res = await this.gmail.users.threads.get({
146
+ userId: "me",
147
+ id: threadId,
148
+ format: opts?.full ? "full" : "metadata",
149
+ });
150
+
151
+ const thread = res.data;
152
+ if (!thread || !thread.messages) return null;
153
+
154
+ return {
155
+ id: thread.id!,
156
+ historyId: thread.historyId!,
157
+ messages: thread.messages.map((msg) => {
158
+ const raw = mapApiMessage(msg);
159
+ return parseRawToThreadMessage(raw);
160
+ }),
161
+ };
162
+ } catch (err: any) {
163
+ if (err?.code === 404) return null;
164
+ throw err;
165
+ }
166
+ }
167
+
168
+ async getMessage(messageId: string): Promise<Record<string, unknown> | null> {
169
+ try {
170
+ const res = await this.gmail.users.messages.get({
171
+ userId: "me",
172
+ id: messageId,
173
+ format: "full",
174
+ });
175
+
176
+ // Wrap in { message: ... } to match gog output shape consumed by monitor.ts
177
+ return { message: mapApiMessage(res.data) };
178
+ } catch (err: any) {
179
+ if (err?.code === 404) return null;
180
+ throw err;
181
+ }
182
+ }
183
+
184
+ async searchMessages(
185
+ query: string,
186
+ opts?: { maxResults?: number; includeBody?: boolean },
187
+ ): Promise<GogSearchMessage[]> {
188
+ const res = await this.gmail.users.messages.list({
189
+ userId: "me",
190
+ q: query,
191
+ maxResults: opts?.maxResults ?? 50,
192
+ });
193
+
194
+ const ids = res.data.messages || [];
195
+ if (ids.length === 0) return [];
196
+
197
+ // Fetch full message details in parallel (N+1 pattern)
198
+ const messages = await Promise.all(
199
+ ids.map(async (m) => {
200
+ try {
201
+ const detail = await this.gmail.users.messages.get({
202
+ userId: "me",
203
+ id: m.id!,
204
+ format: "full",
205
+ });
206
+ return detail.data;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }),
211
+ );
212
+
213
+ return messages
214
+ .filter((m): m is gmail_v1.Schema$Message => m !== null)
215
+ .map((msg) => {
216
+ const headers = msg.payload?.headers || [];
217
+ const getH = (n: string) =>
218
+ headers.find((h) => h.name?.toLowerCase() === n.toLowerCase())?.value || "";
219
+
220
+ const body = extractPlainText(msg.payload ?? {});
221
+
222
+ return {
223
+ id: msg.id!,
224
+ threadId: msg.threadId!,
225
+ date: getH("Date"),
226
+ from: getH("From"),
227
+ subject: getH("Subject"),
228
+ body,
229
+ labels: msg.labelIds || [],
230
+ };
231
+ });
232
+ }
233
+
234
+ async searchThreads(
235
+ query: string,
236
+ opts?: { maxResults?: number },
237
+ ): Promise<Record<string, unknown> | null> {
238
+ const res = await this.gmail.users.threads.list({
239
+ userId: "me",
240
+ q: query,
241
+ maxResults: opts?.maxResults ?? 50,
242
+ });
243
+ return res.data as Record<string, unknown>;
244
+ }
245
+
246
+ async modifyLabels(id: string, opts: { add?: string[]; remove?: string[] }): Promise<void> {
247
+ await this.gmail.users.messages.modify({
248
+ userId: "me",
249
+ id,
250
+ requestBody: {
251
+ addLabelIds: opts.add,
252
+ removeLabelIds: opts.remove,
253
+ },
254
+ });
255
+ }
256
+
257
+ async modifyThreadLabels(
258
+ threadId: string,
259
+ opts: { add?: string[]; remove?: string[] },
260
+ ): Promise<void> {
261
+ await this.gmail.users.threads.modify({
262
+ userId: "me",
263
+ id: threadId,
264
+ requestBody: {
265
+ addLabelIds: opts.add,
266
+ removeLabelIds: opts.remove,
267
+ },
268
+ });
269
+ }
270
+
271
+ async listLabels(): Promise<{ id: string; name: string }[]> {
272
+ const res = await this.gmail.users.labels.list({ userId: "me" });
273
+ return (res.data.labels || []).map((l) => ({
274
+ id: l.id!,
275
+ name: l.name!,
276
+ }));
277
+ }
278
+
279
+ async createLabel(name: string): Promise<void> {
280
+ await this.gmail.users.labels.create({
281
+ userId: "me",
282
+ requestBody: { name },
283
+ });
284
+ }
285
+
286
+ async downloadAttachment(
287
+ messageId: string,
288
+ attachmentId: string,
289
+ outPath: string,
290
+ ): Promise<void> {
291
+ const res = await this.gmail.users.messages.attachments.get({
292
+ userId: "me",
293
+ messageId,
294
+ id: attachmentId,
295
+ });
296
+ // Gmail API returns base64url-encoded data
297
+ const buf = Buffer.from(res.data.data!, "base64url");
298
+ await fs.writeFile(outPath, buf);
299
+ }
300
+
301
+ async getSendAs(): Promise<{ displayName?: string; email: string; isPrimary?: boolean }[]> {
302
+ const res = await this.gmail.users.settings.sendAs.list({ userId: "me" });
303
+ return (res.data.sendAs || []).map((s) => ({
304
+ displayName: s.displayName || undefined,
305
+ email: s.sendAsEmail!,
306
+ isPrimary: s.isPrimary || false,
307
+ }));
308
+ }
309
+ }
310
+
311
+ // ── Response mapping helpers ──────────────────────────────────────────
312
+
313
+ /**
314
+ * Map Gmail API Schema$Message to GogRawMessage shape.
315
+ * This keeps all downstream consumers (quoting.ts, inbound.ts, monitor.ts) working
316
+ * without changes.
317
+ */
318
+ function mapApiMessage(msg: gmail_v1.Schema$Message): GogRawMessage {
319
+ return {
320
+ id: msg.id!,
321
+ threadId: msg.threadId!,
322
+ internalDate: msg.internalDate!,
323
+ labelIds: msg.labelIds || [],
324
+ payload: mapPayload(msg.payload ?? {}),
325
+ };
326
+ }
327
+
328
+ function mapPayload(
329
+ p: gmail_v1.Schema$MessagePart,
330
+ ): GogRawMessage["payload"] {
331
+ return {
332
+ headers: (p.headers || []).map((h) => ({
333
+ name: h.name!,
334
+ value: h.value!,
335
+ })),
336
+ parts: p.parts?.map((part) => ({
337
+ body: part.body?.data ? { data: part.body.data } : undefined,
338
+ mimeType: part.mimeType!,
339
+ })),
340
+ body: p.body?.data ? { data: p.body.data } : undefined,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Parse a GogRawMessage into a ThreadMessage (same logic as quoting.ts:parseGogMessage).
346
+ */
347
+ function parseRawToThreadMessage(raw: GogRawMessage) {
348
+ const getH = (name: string) =>
349
+ raw.payload.headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value;
350
+
351
+ const body = extractPlainFromRaw(raw);
352
+ const bodyHtml = extractHtmlFromRaw(raw);
353
+
354
+ return {
355
+ id: raw.id,
356
+ threadId: raw.threadId,
357
+ date: getH("Date") || new Date(parseInt(raw.internalDate)).toISOString(),
358
+ from: getH("From") || "",
359
+ to: getH("To"),
360
+ cc: getH("Cc"),
361
+ subject: getH("Subject") || "",
362
+ body,
363
+ bodyHtml,
364
+ labels: raw.labelIds,
365
+ };
366
+ }
367
+
368
+ function extractPlainFromRaw(raw: GogRawMessage): string {
369
+ if (raw.payload.parts) {
370
+ const plain = raw.payload.parts.find((p) => p.mimeType === "text/plain");
371
+ if (plain?.body?.data) return Buffer.from(plain.body.data, "base64").toString("utf-8");
372
+ }
373
+ if (raw.payload.body?.data) return Buffer.from(raw.payload.body.data, "base64").toString("utf-8");
374
+ return "";
375
+ }
376
+
377
+ function extractHtmlFromRaw(raw: GogRawMessage): string {
378
+ if (raw.payload.parts) {
379
+ const html = raw.payload.parts.find((p) => p.mimeType === "text/html");
380
+ if (html?.body?.data) return Buffer.from(html.body.data, "base64").toString("utf-8");
381
+ }
382
+ if (raw.payload.body?.data) return Buffer.from(raw.payload.body.data, "base64").toString("utf-8");
383
+ return "";
384
+ }
385
+
386
+ /**
387
+ * Extract plain text body from a Gmail API MessagePart.
388
+ */
389
+ function extractPlainText(part: gmail_v1.Schema$MessagePart): string {
390
+ if (part.mimeType === "text/plain" && part.body?.data) {
391
+ return Buffer.from(part.body.data, "base64").toString("utf-8");
392
+ }
393
+ if (part.parts) {
394
+ // multipart/alternative: prefer text/plain
395
+ if (part.mimeType === "multipart/alternative") {
396
+ const plain = part.parts.find((p) => p.mimeType === "text/plain");
397
+ if (plain) return extractPlainText(plain);
398
+ }
399
+ // Recurse into sub-parts
400
+ for (const sub of part.parts) {
401
+ const text = extractPlainText(sub);
402
+ if (text) return text;
403
+ }
404
+ }
405
+ return "";
406
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,217 @@
1
+ import { OAuth2Client } from "google-auth-library";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import http from "node:http";
6
+ import { execFile } from "node:child_process";
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+
9
+ export interface OAuthCredentials {
10
+ clientId: string;
11
+ clientSecret: string;
12
+ refreshToken: string;
13
+ }
14
+
15
+ /**
16
+ * Read gog CLI's credentials.json to reuse its OAuth client_id/client_secret.
17
+ * Returns null if the file doesn't exist or is malformed.
18
+ */
19
+ export function readGogCredentials(): { clientId: string; clientSecret: string } | null {
20
+ const credPath = path.join(os.homedir(), ".config", "gogcli", "credentials.json");
21
+ try {
22
+ const raw = fs.readFileSync(credPath, "utf-8");
23
+ const data = JSON.parse(raw);
24
+ if (typeof data.client_id === "string" && typeof data.client_secret === "string") {
25
+ return { clientId: data.client_id, clientSecret: data.client_secret };
26
+ }
27
+ return null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Resolve OAuth credentials from config, falling back to gog's credentials.json
35
+ * for client_id/client_secret.
36
+ *
37
+ * Returns full credentials (including refreshToken) only if all three parts are available.
38
+ * Returns null if refresh token is missing (caller should run the OAuth flow).
39
+ */
40
+ export function resolveOAuthCredentials(
41
+ accountEmail: string,
42
+ cfg: OpenClawConfig,
43
+ ): OAuthCredentials | null {
44
+ const accounts = (cfg.channels as any)?.["openclaw-gmail"]?.accounts;
45
+ const account = accounts?.[accountEmail] ?? Object.values(accounts ?? {}).find(
46
+ (a: any) => a.email === accountEmail,
47
+ );
48
+
49
+ const oauth = (account as any)?.oauth;
50
+ if (oauth?.clientId && oauth?.clientSecret && oauth?.refreshToken) {
51
+ return {
52
+ clientId: oauth.clientId,
53
+ clientSecret: oauth.clientSecret,
54
+ refreshToken: oauth.refreshToken,
55
+ };
56
+ }
57
+
58
+ // If we have a refresh token in config but client creds come from gog
59
+ if (oauth?.refreshToken) {
60
+ const gogCreds = readGogCredentials();
61
+ if (gogCreds) {
62
+ return {
63
+ clientId: oauth.clientId || gogCreds.clientId,
64
+ clientSecret: oauth.clientSecret || gogCreds.clientSecret,
65
+ refreshToken: oauth.refreshToken,
66
+ };
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Resolve just the client_id/client_secret pair (without refresh token).
75
+ * Useful for initiating the OAuth flow.
76
+ */
77
+ export function resolveClientCredentials(
78
+ accountEmail: string,
79
+ cfg: OpenClawConfig,
80
+ ): { clientId: string; clientSecret: string } | null {
81
+ const accounts = (cfg.channels as any)?.["openclaw-gmail"]?.accounts;
82
+ const account = accounts?.[accountEmail] ?? Object.values(accounts ?? {}).find(
83
+ (a: any) => a.email === accountEmail,
84
+ );
85
+
86
+ const oauth = (account as any)?.oauth;
87
+ if (oauth?.clientId && oauth?.clientSecret) {
88
+ return { clientId: oauth.clientId, clientSecret: oauth.clientSecret };
89
+ }
90
+
91
+ return readGogCredentials();
92
+ }
93
+
94
+ /**
95
+ * Create an authenticated OAuth2Client with auto-refresh.
96
+ */
97
+ export function createOAuth2Client(creds: OAuthCredentials): OAuth2Client {
98
+ const client = new OAuth2Client(creds.clientId, creds.clientSecret);
99
+ client.setCredentials({ refresh_token: creds.refreshToken });
100
+ return client;
101
+ }
102
+
103
+ /**
104
+ * Open a URL in the user's default browser.
105
+ */
106
+ function openBrowser(url: string): void {
107
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
108
+ execFile(cmd, [url], (err) => {
109
+ if (err) {
110
+ // If browser open fails, the URL is already printed to console
111
+ }
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Run the browser-based OAuth2 consent flow.
117
+ *
118
+ * 1. Starts a local HTTP server on 127.0.0.1
119
+ * 2. Opens the consent URL in the user's browser
120
+ * 3. Waits for the redirect callback with the auth code
121
+ * 4. Exchanges the code for tokens
122
+ * 5. Returns the refresh_token
123
+ */
124
+ export async function runOAuthFlow(
125
+ clientId: string,
126
+ clientSecret: string,
127
+ opts?: { port?: number },
128
+ ): Promise<string> {
129
+ const port = opts?.port ?? 0; // 0 = OS picks a free port
130
+
131
+ return new Promise<string>((resolve, reject) => {
132
+ const server = http.createServer();
133
+
134
+ const timeout = setTimeout(() => {
135
+ server.close();
136
+ reject(new Error("OAuth flow timed out after 5 minutes"));
137
+ }, 5 * 60 * 1000);
138
+
139
+ server.listen(port, "127.0.0.1", () => {
140
+ const addr = server.address();
141
+ if (!addr || typeof addr === "string") {
142
+ clearTimeout(timeout);
143
+ server.close();
144
+ reject(new Error("Failed to start local OAuth server"));
145
+ return;
146
+ }
147
+
148
+ const redirectUri = `http://127.0.0.1:${addr.port}/callback`;
149
+ const oauth2Client = new OAuth2Client(clientId, clientSecret, redirectUri);
150
+
151
+ const authUrl = oauth2Client.generateAuthUrl({
152
+ access_type: "offline",
153
+ scope: ["https://mail.google.com/"],
154
+ prompt: "consent",
155
+ });
156
+
157
+ console.log(`\nOpen this URL in your browser to authorize:\n\n ${authUrl}\n`);
158
+ openBrowser(authUrl);
159
+
160
+ server.on("request", async (req, res) => {
161
+ if (!req.url?.startsWith("/callback")) {
162
+ res.writeHead(404);
163
+ res.end("Not found");
164
+ return;
165
+ }
166
+
167
+ const url = new URL(req.url, `http://127.0.0.1:${addr.port}`);
168
+ const code = url.searchParams.get("code");
169
+ const error = url.searchParams.get("error");
170
+
171
+ if (error) {
172
+ res.writeHead(200, { "Content-Type": "text/html" });
173
+ res.end("<h1>Authorization denied</h1><p>You can close this tab.</p>");
174
+ clearTimeout(timeout);
175
+ server.close();
176
+ reject(new Error(`OAuth authorization denied: ${error}`));
177
+ return;
178
+ }
179
+
180
+ if (!code) {
181
+ res.writeHead(400, { "Content-Type": "text/html" });
182
+ res.end("<h1>Missing authorization code</h1>");
183
+ return;
184
+ }
185
+
186
+ try {
187
+ const { tokens } = await oauth2Client.getToken(code);
188
+ if (!tokens.refresh_token) {
189
+ res.writeHead(200, { "Content-Type": "text/html" });
190
+ res.end("<h1>Error</h1><p>No refresh token received. Try revoking access at <a href='https://myaccount.google.com/permissions'>Google Account Permissions</a> and retry.</p>");
191
+ clearTimeout(timeout);
192
+ server.close();
193
+ reject(new Error("No refresh_token received. Revoke app access and retry with prompt=consent."));
194
+ return;
195
+ }
196
+
197
+ res.writeHead(200, { "Content-Type": "text/html" });
198
+ res.end("<h1>Authorization successful!</h1><p>You can close this tab and return to the terminal.</p>");
199
+ clearTimeout(timeout);
200
+ server.close();
201
+ resolve(tokens.refresh_token);
202
+ } catch (err) {
203
+ res.writeHead(500, { "Content-Type": "text/html" });
204
+ res.end("<h1>Token exchange failed</h1><p>Check the terminal for details.</p>");
205
+ clearTimeout(timeout);
206
+ server.close();
207
+ reject(err);
208
+ }
209
+ });
210
+ });
211
+
212
+ server.on("error", (err) => {
213
+ clearTimeout(timeout);
214
+ reject(err);
215
+ });
216
+ });
217
+ }