@mcinteerj/openclaw-gmail 1.3.0 → 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/src/monitor.ts CHANGED
@@ -1,17 +1,14 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import fs from "node:fs/promises";
4
2
  import path from "node:path";
5
3
  import os from "node:os";
6
4
  import lockfile from "proper-lockfile";
7
5
  import type { ChannelLogSink, InboundMessage } from "openclaw/plugin-sdk";
8
6
  import type { ResolvedGmailAccount } from "./accounts.js";
9
- import { loadHistoryId, saveHistoryId } from "./history-store.js";
10
7
  import { parseInboundGmail, parseSearchGmail, type GogPayload, type GogSearchMessage } from "./inbound.js";
11
- import { extractAttachments, type GmailAttachment } from "./attachments.js";
8
+ import { extractAttachments } from "./attachments.js";
12
9
  import { isAllowed } from "./normalize.js";
13
-
14
- const execFileAsync = promisify(execFile);
10
+ import type { GmailClient } from "./gmail-client.js";
11
+ import { GogGmailClient } from "./gog-client.js";
15
12
 
16
13
  // Polling interval: Default 60s, override via env for testing
17
14
  const DEFAULT_POLL_INTERVAL = 60_000;
@@ -29,118 +26,28 @@ const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve)
29
26
  }, { once: true });
30
27
  });
31
28
 
32
- const GOG_TIMEOUT_MS = 30_000;
33
- const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
34
-
35
29
  // Local deduplication cache to prevent re-dispatching messages before Gmail updates labels
36
30
  const dispatchedMessageIds = new Set<string>();
37
31
  // Clear cache periodically to prevent memory growth (every hour)
38
32
  setInterval(() => dispatchedMessageIds.clear(), 60 * 60 * 1000).unref();
39
33
 
40
- interface CircuitState {
41
- consecutiveFailures: number;
42
- lastFailureAt: number;
43
- backoffUntil: number;
44
- }
45
-
46
- const CIRCUIT_CONFIG = {
47
- maxFailures: 3,
48
- initialBackoffMs: 60_000, // 1 minute
49
- maxBackoffMs: 15 * 60_000, // 15 minutes
50
- };
51
-
52
- const circuitStates = new Map<string, CircuitState>();
53
-
54
- function getCircuit(email: string): CircuitState {
55
- if (!circuitStates.has(email)) {
56
- circuitStates.set(email, { consecutiveFailures: 0, lastFailureAt: 0, backoffUntil: 0 });
57
- }
58
- return circuitStates.get(email)!;
59
- }
60
-
61
- async function checkGogExists(): Promise<boolean> {
62
- try {
63
- await execFileAsync("gog", ["--version"]);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- async function runGog(args: string[], accountEmail: string, retries = 3): Promise<Record<string, unknown> | null> {
71
- const circuit = getCircuit(accountEmail);
72
- const now = Date.now();
73
-
74
- if (now < circuit.backoffUntil) {
75
- throw new Error(`Circuit breaker active for ${accountEmail}. Backing off until ${new Date(circuit.backoffUntil).toISOString()}`);
76
- }
77
-
78
- const allArgs = ["--json", "--account", accountEmail, ...args];
79
- let lastErr: any;
80
-
81
- for (let i = 0; i < retries; i++) {
82
- try {
83
- const { stdout } = await execFileAsync("gog", allArgs, {
84
- maxBuffer: 10 * 1024 * 1024,
85
- timeout: GOG_TIMEOUT_MS,
86
- });
87
-
88
- // Success - reset circuit
89
- circuit.consecutiveFailures = 0;
90
- circuit.backoffUntil = 0;
91
-
92
- if (!stdout.trim()) return null;
93
- return JSON.parse(stdout) as Record<string, unknown>;
94
- } catch (err: unknown) {
95
- lastErr = err;
96
- const msg = String(err);
97
-
98
- // Don't retry/backoff on certain errors (e.g. 404 means no messages, not a failure)
99
- if (msg.includes("404")) {
100
- return null;
101
- }
102
-
103
- if (msg.includes("403") || msg.includes("invalid_grant") || msg.includes("ETIMEDOUT") || msg.includes("500")) {
104
- circuit.consecutiveFailures++;
105
- circuit.lastFailureAt = Date.now();
106
-
107
- if (circuit.consecutiveFailures >= CIRCUIT_CONFIG.maxFailures) {
108
- const backoff = Math.min(
109
- CIRCUIT_CONFIG.initialBackoffMs * Math.pow(2, circuit.consecutiveFailures - CIRCUIT_CONFIG.maxFailures),
110
- CIRCUIT_CONFIG.maxBackoffMs
111
- );
112
- circuit.backoffUntil = Date.now() + backoff;
113
- }
114
- break;
115
- }
116
-
117
- if (i < retries - 1) {
118
- await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
119
- }
120
- }
121
- }
122
-
123
- const error = lastErr as { stderr?: string; message?: string };
124
- throw new Error(`gog failed: ${error.stderr || error.message || String(lastErr)}`);
125
- }
126
-
127
- export async function quarantineMessage(id: string, accountEmail: string, log: ChannelLogSink) {
34
+ export async function quarantineMessage(id: string, log: ChannelLogSink, client: GmailClient) {
128
35
  try {
129
36
  // Add 'not-allow-listed', remove 'INBOX', leave UNREAD
130
- await runGog(["gmail", "labels", "modify", id, "--add", "not-allow-listed", "--remove", "INBOX"], accountEmail);
131
- log.info(`Quarantined message ${id} from disallowed sender (moved to not-allow-listed, removed from INBOX)`);
37
+ await client.modifyLabels(id, { add: [QUARANTINE_LABEL], remove: ["INBOX"] });
38
+ log.info(`Quarantined message ${id} from disallowed sender (moved to ${QUARANTINE_LABEL}, removed from INBOX)`);
132
39
  } catch (err) {
133
40
  log.error(`Failed to quarantine message ${id}: ${String(err)}`);
134
41
  }
135
42
  }
136
43
 
137
- async function markAsRead(id: string, threadId: string | undefined, accountEmail: string, log: ChannelLogSink) {
44
+ async function markAsRead(id: string, threadId: string | undefined, log: ChannelLogSink, client: GmailClient) {
138
45
  try {
139
46
  // Prefer thread-level modification as it's more robust in Gmail for label propagation
140
47
  if (threadId) {
141
- await runGog(["gmail", "thread", "modify", threadId, "--remove", "UNREAD"], accountEmail);
48
+ await client.modifyThreadLabels(threadId, { remove: ["UNREAD"] });
142
49
  } else {
143
- await runGog(["gmail", "labels", "modify", id, "--remove", "UNREAD"], accountEmail);
50
+ await client.modifyLabels(id, { remove: ["UNREAD"] });
144
51
  }
145
52
  } catch (err) {
146
53
  log.error(`Failed to mark ${id} as read: ${String(err)}`);
@@ -186,7 +93,7 @@ async function pruneGmailSessions(account: ResolvedGmailAccount, log: ChannelLog
186
93
  if (entry.updatedAt && now - entry.updatedAt > ttlMs) {
187
94
  // Found an expired session
188
95
  const threadId = key.split(":").pop();
189
-
96
+
190
97
  // Delete associated attachments directory if it exists
191
98
  if (threadId) {
192
99
  const threadAttachmentsDir = path.join(attachmentsDir, threadId);
@@ -223,10 +130,11 @@ async function fetchMessageDetails(
223
130
  id: string,
224
131
  account: ResolvedGmailAccount,
225
132
  log: ChannelLogSink,
133
+ client: GmailClient,
226
134
  ignoreLabels = false
227
135
  ): Promise<InboundMessage | null> {
228
136
  try {
229
- const res = await runGog(["gmail", "get", id], account.email);
137
+ const res = await client.getMessage(id);
230
138
  if (!res) return null;
231
139
 
232
140
  const message = (res.message || res) as Record<string, unknown>;
@@ -250,12 +158,13 @@ async function fetchMessageDetails(
250
158
  }
251
159
 
252
160
  async function downloadAttachmentsIfSmall(
253
- msg: InboundMessage,
254
- account: ResolvedGmailAccount,
255
- log: ChannelLogSink
161
+ msg: InboundMessage,
162
+ account: ResolvedGmailAccount,
163
+ log: ChannelLogSink,
164
+ client: GmailClient,
256
165
  ): Promise<string[]> {
257
166
  if (!msg.raw || !msg.raw.payload) return [];
258
-
167
+
259
168
  const attachments = extractAttachments(msg.raw.payload);
260
169
  const downloaded: string[] = [];
261
170
 
@@ -269,18 +178,11 @@ async function downloadAttachmentsIfSmall(
269
178
  const ext = path.extname(att.filename) || "";
270
179
  const safeName = path.basename(att.filename, ext).replace(/[^a-z0-9]/gi, '_') + ext;
271
180
  const outPath = path.join(threadAttachmentsDir, safeName);
272
-
181
+
273
182
  await fs.mkdir(threadAttachmentsDir, { recursive: true });
274
-
275
- // Use gog to download
276
- await execFileAsync("gog", [
277
- "gmail", "attachment",
278
- msg.channelMessageId,
279
- att.attachmentId,
280
- "--account", account.email,
281
- "--out", outPath
282
- ]);
283
-
183
+
184
+ await client.downloadAttachment(msg.channelMessageId, att.attachmentId, outPath);
185
+
284
186
  downloaded.push(outPath);
285
187
  log.info(`Auto-downloaded attachment ${att.filename} to ${outPath}`);
286
188
  } catch (err) {
@@ -295,34 +197,29 @@ async function performFullSync(
295
197
  account: ResolvedGmailAccount,
296
198
  onMessage: (msg: InboundMessage) => Promise<void>,
297
199
  signal: AbortSignal,
298
- log: ChannelLogSink
200
+ log: ChannelLogSink,
201
+ client: GmailClient,
299
202
  ): Promise<string | null> {
300
203
  // Use label:INBOX label:UNREAD for the most reliable bot inbox pattern
301
- // We explicitly ask for JSON output and include-body
302
- const searchResult = await runGog([
303
- "gmail", "messages", "search",
304
- "label:INBOX label:UNREAD",
305
- "--include-body",
306
- "--max", "50"
307
- ], account.email);
308
-
309
- if (!searchResult || !Array.isArray((searchResult as any).messages)) {
310
- return null;
311
- }
204
+ const rawMessages = await client.searchMessages("label:INBOX label:UNREAD", {
205
+ maxResults: 50,
206
+ includeBody: true,
207
+ });
208
+
209
+ if (rawMessages.length === 0) return null;
312
210
 
313
- const rawMessages = (searchResult as any).messages as GogSearchMessage[];
314
211
  const inboundMessages: InboundMessage[] = [];
315
212
 
316
213
  for (const raw of rawMessages) {
317
214
  if (signal.aborted) break;
318
-
215
+
319
216
  // Parse the simplified search result
320
217
  const msg = parseSearchGmail(raw, account.accountId, account.email);
321
-
218
+
322
219
  if (msg) {
323
220
  if (!isAllowed(msg.sender.id, account.allowFrom || [])) {
324
221
  log.warn(`Quarantining email from non-whitelisted sender: ${msg.sender.id}`);
325
- await quarantineMessage(msg.channelMessageId, account.email, log);
222
+ await quarantineMessage(msg.channelMessageId, log, client);
326
223
  continue;
327
224
  }
328
225
  inboundMessages.push(msg);
@@ -338,14 +235,14 @@ async function performFullSync(
338
235
 
339
236
  for (const [threadId, messages] of threads) {
340
237
  if (signal.aborted) break;
341
-
238
+
342
239
  // Filter out messages we've already dispatched in this session
343
240
  const newMessages = messages.filter(msg => !dispatchedMessageIds.has(msg.channelMessageId));
344
241
  if (newMessages.length === 0) continue;
345
242
 
346
243
  log.info(`[Sync] Processing thread ${threadId} with ${newMessages.length} new messages`);
347
244
  newMessages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
348
-
245
+
349
246
  for (const msg of newMessages) {
350
247
  if (signal.aborted) break;
351
248
 
@@ -353,23 +250,23 @@ async function performFullSync(
353
250
  dispatchedMessageIds.add(msg.channelMessageId);
354
251
 
355
252
  // To get attachments, we need the full message details (search --include-body only gives text)
356
- const fullMsg = await fetchMessageDetails(msg.channelMessageId, account, log, true);
253
+ const fullMsg = await fetchMessageDetails(msg.channelMessageId, account, log, client, true);
357
254
  const msgToDispatch = fullMsg || msg;
358
255
 
359
256
  try {
360
257
  // Auto-download small attachments
361
258
  if (fullMsg) {
362
- const downloadedPaths = await downloadAttachmentsIfSmall(fullMsg, account, log);
259
+ const downloadedPaths = await downloadAttachmentsIfSmall(fullMsg, account, log, client);
363
260
  if (downloadedPaths.length > 0) {
364
- msgToDispatch.text += "\n\n### Auto-downloaded Files\n" +
261
+ msgToDispatch.text += "\n\n### Auto-downloaded Files\n" +
365
262
  downloadedPaths.map(p => `- \`${p}\``).join("\n");
366
263
  }
367
264
  }
368
265
 
369
266
  await onMessage(msgToDispatch);
370
-
267
+
371
268
  // CRITICAL: Only mark as read after successful dispatch
372
- await markAsRead(msg.channelMessageId, msg.threadId, account.email, log);
269
+ await markAsRead(msg.channelMessageId, msg.threadId, log, client);
373
270
  } catch (err) {
374
271
  log.error(`Failed to dispatch message ${msg.channelMessageId}, leaving as UNREAD: ${String(err)}`);
375
272
  // Remove from dedupe so it's retried next tick
@@ -379,27 +276,23 @@ async function performFullSync(
379
276
  }
380
277
 
381
278
  // Get latest history ID to resume polling
382
- const latest = await runGog(["gmail", "search", "label:INBOX", "--max", "1"], account.email);
279
+ const latest = await client.searchThreads("label:INBOX", { maxResults: 1 });
383
280
  if ((latest as any)?.threads?.[0]) {
384
- // We still need a separate fetch for historyId as search result (thread list) might not have it
385
- // But this is just 1 call.
386
- const thread = await runGog(["gmail", "get", (latest as any).threads[0].id], account.email);
281
+ const thread = await client.getMessage((latest as any).threads[0].id);
387
282
  const nextId = (thread as any)?.message?.historyId || (thread as any)?.historyId || null;
388
283
  return nextId;
389
284
  }
390
285
  return null;
391
286
  }
392
287
 
393
- async function ensureQuarantineLabel(accountEmail: string, log: ChannelLogSink) {
288
+ async function ensureQuarantineLabel(log: ChannelLogSink, client: GmailClient) {
394
289
  try {
395
- const res = await runGog(["gmail", "labels", "list"], accountEmail);
396
- // res.labels is likely the array from the JSON output
397
- const labels = (res as any)?.labels || [];
398
- const exists = labels.some((l: any) => l.name === QUARANTINE_LABEL);
290
+ const labels = await client.listLabels();
291
+ const exists = labels.some((l) => l.name === QUARANTINE_LABEL);
399
292
 
400
293
  if (!exists) {
401
294
  log.info(`Creating quarantine label '${QUARANTINE_LABEL}'...`);
402
- await runGog(["gmail", "labels", "create", QUARANTINE_LABEL], accountEmail);
295
+ await client.createLabel(QUARANTINE_LABEL);
403
296
  }
404
297
  } catch (err) {
405
298
  // If this fails, quarantine attempts will also fail, but we don't block startup
@@ -413,11 +306,12 @@ export async function monitorGmail(params: {
413
306
  signal: AbortSignal;
414
307
  log: ChannelLogSink;
415
308
  setStatus: (status: any) => void;
309
+ client: GmailClient;
416
310
  }) {
417
- const { account, onMessage, signal, log, setStatus } = params;
418
-
419
- // Doctor check
420
- if (!(await checkGogExists())) {
311
+ const { account, onMessage, signal, log, setStatus, client } = params;
312
+
313
+ // Doctor check — only require gog CLI for the gog backend
314
+ if (account.backend !== "api" && !(await GogGmailClient.checkExists())) {
421
315
  log.error("gog CLI not found in PATH. Gmail channel disabled.");
422
316
  setStatus({ accountId: account.accountId, running: false, connected: false, error: "gog CLI missing" });
423
317
  return;
@@ -426,7 +320,7 @@ export async function monitorGmail(params: {
426
320
  log.info(`Starting monitor for ${account.email}`);
427
321
 
428
322
  // Ensure quarantine label exists
429
- await ensureQuarantineLabel(account.email, log);
323
+ await ensureQuarantineLabel(log, client);
430
324
 
431
325
  // Prune on start
432
326
  await pruneGmailSessions(account, log);
@@ -457,7 +351,7 @@ export async function monitorGmail(params: {
457
351
  isSyncing = true;
458
352
  try {
459
353
  log.debug("Performing full search sync...");
460
- await performFullSync(account, onMessage, signal, log);
354
+ await performFullSync(account, onMessage, signal, log, client);
461
355
  setStatus({ accountId: account.accountId, running: true, connected: true, lastError: undefined });
462
356
  } finally {
463
357
  isSyncing = false;
package/src/onboarding.ts CHANGED
@@ -3,6 +3,8 @@ import { promptAccountId } from "openclaw/plugin-sdk";
3
3
  import { exec } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { listGmailAccountIds, resolveDefaultGmailAccountId } from "./accounts.js";
6
+ import { readGogCredentials, runOAuthFlow, createOAuth2Client } from "./auth.js";
7
+ import { ApiGmailClient } from "./api-client.js";
6
8
 
7
9
  const execAsync = promisify(exec);
8
10
  const channel = "gmail" as const;
@@ -69,16 +71,43 @@ async function fetchGmailName(email: string): Promise<string | undefined> {
69
71
  }
70
72
  }
71
73
 
74
+ async function fetchApiDisplayName(
75
+ clientId: string,
76
+ clientSecret: string,
77
+ refreshToken: string,
78
+ ): Promise<string | undefined> {
79
+ try {
80
+ const auth = createOAuth2Client({ clientId, clientSecret, refreshToken });
81
+ const client = new ApiGmailClient(auth);
82
+ const sendAs = await client.getSendAs();
83
+ const primary = sendAs.find((s) => s.isPrimary) || sendAs[0];
84
+ return primary?.displayName;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
72
90
  export const gmailOnboardingAdapter: ChannelOnboardingAdapter = {
73
91
  channel,
74
92
  getStatus: async ({ cfg }: { cfg: OpenClawConfig }) => {
75
93
  const ids = listGmailAccountIds(cfg);
76
94
  const configured = ids.length > 0;
95
+ const gmailConfig = (cfg.channels as any)?.gmail || {};
96
+ const accounts = gmailConfig.accounts || {};
97
+ const backends = new Set(
98
+ Object.values(accounts).map((a: any) => a.backend || "gog"),
99
+ );
100
+ let hint = "Gmail polling";
101
+ if (backends.size === 1) {
102
+ hint = backends.has("api") ? "Gmail API" : "gog CLI";
103
+ } else if (backends.size > 1) {
104
+ hint = "Gmail API + gog CLI";
105
+ }
77
106
  return {
78
107
  channel,
79
108
  configured,
80
109
  statusLines: [`Gmail: ${configured ? `${ids.length} accounts` : "not configured"}`],
81
- selectionHint: "Polling via gog CLI",
110
+ selectionHint: hint,
82
111
  quickstartScore: configured ? 1 : 5,
83
112
  };
84
113
  },
@@ -97,22 +126,7 @@ export const gmailOnboardingAdapter: ChannelOnboardingAdapter = {
97
126
  accountOverrides: Record<string, string>;
98
127
  shouldPromptAccountIds: boolean;
99
128
  }) => {
100
- if (!(await checkGogInstalled())) {
101
- await prompter.note(
102
- "The `gog` CLI is required for the Gmail extension.\nPlease install it and ensure it is in your PATH.",
103
- "Missing Dependency"
104
- );
105
- throw new Error("gog CLI not found");
106
- }
107
-
108
- const version = await getGogVersion();
109
- if (version && !isVersionAtLeast(version, MIN_GOG_VERSION)) {
110
- await prompter.note(
111
- `Your gog version (${version}) is below the recommended ${MIN_GOG_VERSION}.\nSome features may not work correctly.`,
112
- "Version Warning"
113
- );
114
- }
115
-
129
+ // --- Account selection (unchanged) ---
116
130
  const existingIds = listGmailAccountIds(cfg);
117
131
  const gmailOverride = accountOverrides.gmail?.trim();
118
132
  const defaultAccountId = resolveDefaultGmailAccountId(cfg);
@@ -129,6 +143,7 @@ export const gmailOnboardingAdapter: ChannelOnboardingAdapter = {
129
143
  });
130
144
  }
131
145
 
146
+ // --- Email prompt (unchanged) ---
132
147
  let email = accountId.includes("@") ? accountId : undefined;
133
148
  if (!email) {
134
149
  email = await prompter.text({
@@ -136,26 +151,141 @@ export const gmailOnboardingAdapter: ChannelOnboardingAdapter = {
136
151
  validate: (val: string | undefined) => (val?.includes("@") ? undefined : "Valid email required"),
137
152
  });
138
153
  }
139
-
140
154
  if (!email) throw new Error("Email required");
141
155
 
142
- const isAuthed = await checkGogAuth(email);
143
- if (!isAuthed) {
156
+ // --- Detect available auth sources ---
157
+ const gmailConfig = (cfg.channels as any)?.gmail || {};
158
+ const existingAccount = gmailConfig.accounts?.[email];
159
+ const existingOAuth = existingAccount?.oauth;
160
+ const gogInstalled = await checkGogInstalled();
161
+ const gogCreds = readGogCredentials();
162
+
163
+ // --- Determine backend ---
164
+ let backend: "api" | "gog";
165
+ let clientId: string | undefined;
166
+ let clientSecret: string | undefined;
167
+ let refreshToken: string | undefined;
168
+
169
+ if (existingOAuth?.clientId && existingOAuth?.clientSecret && existingOAuth?.refreshToken) {
170
+ // Scenario D: Existing API user reconfiguring
171
+ backend = "api";
172
+ clientId = existingOAuth.clientId;
173
+ clientSecret = existingOAuth.clientSecret;
174
+ refreshToken = existingOAuth.refreshToken;
175
+
144
176
  await prompter.note(
145
- `Gog CLI is not authorized for ${email}. We need to authorize it now.`,
146
- "Authorization"
177
+ `Using existing API credentials for ${email}.`,
178
+ "OAuth Credentials"
147
179
  );
148
- const doAuth = await prompter.confirm({
149
- message: "Authorize gog now?",
180
+
181
+ const reAuth = await prompter.confirm({
182
+ message: "Re-authorize with Google? (only needed if token expired)",
183
+ initialValue: false,
184
+ });
185
+ if (reAuth) {
186
+ refreshToken = await runOAuthFlow(clientId, clientSecret);
187
+ }
188
+ } else if (gogInstalled && gogCreds) {
189
+ // Scenario A/C: gog installed with credentials — offer migration
190
+ const migrate = await prompter.confirm({
191
+ message: "Found gog CLI credentials. Migrate to direct API access? (recommended, one-time browser auth)",
150
192
  initialValue: true,
151
193
  });
152
- if (doAuth) {
153
- await authorizeGog(email);
194
+
195
+ if (migrate) {
196
+ backend = "api";
197
+ clientId = gogCreds.clientId;
198
+ clientSecret = gogCreds.clientSecret;
199
+ await prompter.note(
200
+ "Reusing your gog OAuth client credentials.\nA browser window will open for one-time authorization.",
201
+ "API Migration"
202
+ );
203
+ refreshToken = await runOAuthFlow(clientId, clientSecret);
154
204
  } else {
155
- await prompter.note("Skipping auth. You must run `gog auth add " + email + "` manually.", "Warning");
205
+ backend = "gog";
206
+ }
207
+ } else if (gogInstalled) {
208
+ // gog installed but no credentials file — offer choice
209
+ const useApi = await prompter.confirm({
210
+ message: "Use direct Gmail API access? (recommended; otherwise uses gog CLI)",
211
+ initialValue: true,
212
+ });
213
+
214
+ if (useApi) {
215
+ backend = "api";
216
+ } else {
217
+ backend = "gog";
218
+ }
219
+ } else {
220
+ // Scenario B: No gog — API is the only option
221
+ backend = "api";
222
+ }
223
+
224
+ // --- API backend: resolve credentials and run OAuth if needed ---
225
+ if (backend === "api" && !refreshToken) {
226
+ // Need client credentials
227
+ if (!clientId || !clientSecret) {
228
+ if (gogCreds) {
229
+ clientId = gogCreds.clientId;
230
+ clientSecret = gogCreds.clientSecret;
231
+ } else {
232
+ await prompter.note(
233
+ "To use Gmail with OpenClaw, you need a Google Cloud OAuth client:\n" +
234
+ "1. Go to https://console.cloud.google.com/apis/credentials\n" +
235
+ "2. Create a project (or use existing)\n" +
236
+ "3. Enable the Gmail API\n" +
237
+ "4. Create OAuth 2.0 Client ID (type: Desktop app)\n" +
238
+ "5. Copy the Client ID and Client Secret",
239
+ "GCP OAuth Setup"
240
+ );
241
+
242
+ clientId = await prompter.text({
243
+ message: "OAuth Client ID",
244
+ validate: (val?: string) => (val?.trim() ? undefined : "Client ID required"),
245
+ });
246
+ clientSecret = await prompter.text({
247
+ message: "OAuth Client Secret",
248
+ validate: (val?: string) => (val?.trim() ? undefined : "Client Secret required"),
249
+ });
250
+ }
156
251
  }
252
+
253
+ await prompter.note(
254
+ "A browser window will open for Gmail authorization.",
255
+ "OAuth Flow"
256
+ );
257
+ refreshToken = await runOAuthFlow(clientId!, clientSecret!);
157
258
  }
158
259
 
260
+ // --- gog backend: version check + auth ---
261
+ if (backend === "gog") {
262
+ const version = await getGogVersion();
263
+ if (version && !isVersionAtLeast(version, MIN_GOG_VERSION)) {
264
+ await prompter.note(
265
+ `Your gog version (${version}) is below the recommended ${MIN_GOG_VERSION}.\nSome features may not work correctly.`,
266
+ "Version Warning"
267
+ );
268
+ }
269
+
270
+ const isAuthed = await checkGogAuth(email);
271
+ if (!isAuthed) {
272
+ await prompter.note(
273
+ `Gog CLI is not authorized for ${email}. We need to authorize it now.`,
274
+ "Authorization"
275
+ );
276
+ const doAuth = await prompter.confirm({
277
+ message: "Authorize gog now?",
278
+ initialValue: true,
279
+ });
280
+ if (doAuth) {
281
+ await authorizeGog(email);
282
+ } else {
283
+ await prompter.note("Skipping auth. You must run `gog auth add " + email + "` manually.", "Warning");
284
+ }
285
+ }
286
+ }
287
+
288
+ // --- Common prompts ---
159
289
  const allowFromRaw = await prompter.text({
160
290
  message: "Allow emails from (comma separated, * for all)",
161
291
  initialValue: "",
@@ -172,17 +302,28 @@ export const gmailOnboardingAdapter: ChannelOnboardingAdapter = {
172
302
  });
173
303
  const pollIntervalMs = parseInt(pollIntervalSecsRaw, 10) * 1000;
174
304
 
175
- const name = await fetchGmailName(email);
305
+ // --- Fetch display name via appropriate backend ---
306
+ let name: string | undefined;
307
+ if (backend === "api" && clientId && clientSecret && refreshToken) {
308
+ name = await fetchApiDisplayName(clientId, clientSecret, refreshToken);
309
+ } else {
310
+ name = await fetchGmailName(email);
311
+ }
176
312
 
177
- const accountConfig = {
313
+ // --- Build account config ---
314
+ const accountConfig: Record<string, unknown> = {
178
315
  enabled: true,
179
316
  email,
180
317
  name,
181
318
  allowFrom,
182
319
  pollIntervalMs,
320
+ backend,
183
321
  };
184
322
 
185
- const gmailConfig = cfg.channels?.gmail || {};
323
+ if (backend === "api" && clientId && clientSecret && refreshToken) {
324
+ accountConfig.oauth = { clientId, clientSecret, refreshToken };
325
+ }
326
+
186
327
  const accounts = gmailConfig.accounts || {};
187
328
 
188
329
  const next = {