@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/package.json +7 -4
- package/src/accounts.ts +8 -0
- package/src/api-client.ts +406 -0
- package/src/auth.ts +217 -0
- package/src/channel.ts +54 -24
- package/src/config.ts +6 -0
- package/src/gmail-client.ts +50 -0
- package/src/gog-client.ts +230 -0
- package/src/mime.ts +37 -0
- package/src/monitor.ts +52 -158
- package/src/onboarding.ts +171 -30
- package/src/outbound-check.ts +67 -107
- package/src/outbound.ts +63 -109
- package/src/quoting.ts +17 -58
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
|
|
8
|
+
import { extractAttachments } from "./attachments.js";
|
|
12
9
|
import { isAllowed } from "./normalize.js";
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
131
|
-
log.info(`Quarantined message ${id} from disallowed sender (moved to
|
|
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,
|
|
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
|
|
48
|
+
await client.modifyThreadLabels(threadId, { remove: ["UNREAD"] });
|
|
142
49
|
} else {
|
|
143
|
-
await
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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,
|
|
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,
|
|
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
|
|
279
|
+
const latest = await client.searchThreads("label:INBOX", { maxResults: 1 });
|
|
383
280
|
if ((latest as any)?.threads?.[0]) {
|
|
384
|
-
|
|
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(
|
|
288
|
+
async function ensureQuarantineLabel(log: ChannelLogSink, client: GmailClient) {
|
|
394
289
|
try {
|
|
395
|
-
const
|
|
396
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
`
|
|
146
|
-
"
|
|
177
|
+
`Using existing API credentials for ${email}.`,
|
|
178
|
+
"OAuth Credentials"
|
|
147
179
|
);
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|