@mcinteerj/openclaw-gmail 1.3.1 → 1.4.1
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 +95 -67
- 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 +52 -22
- 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/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
|
+
}
|
package/src/channel.ts
CHANGED
|
@@ -21,27 +21,27 @@ import { setGmailRuntime, getGmailRuntime } from "./runtime.js";
|
|
|
21
21
|
import { sendGmailText, type GmailOutboundContext } from "./outbound.js";
|
|
22
22
|
import { gmailThreading } from "./threading.js";
|
|
23
23
|
import { normalizeGmailTarget, isGmailThreadId, isAllowed } from "./normalize.js";
|
|
24
|
-
import {
|
|
25
|
-
import { monitorGmail, quarantineMessage } from "./monitor.js";
|
|
26
|
-
import { extractAttachments } from "./attachments.js";
|
|
24
|
+
import { monitorGmail } from "./monitor.js";
|
|
27
25
|
import { Semaphore } from "./semaphore.js";
|
|
26
|
+
import { createGmailClient, type GmailClient } from "./gmail-client.js";
|
|
28
27
|
import crypto from "node:crypto";
|
|
29
28
|
|
|
30
29
|
const meta = {
|
|
31
30
|
id: "gmail",
|
|
32
31
|
label: "Gmail",
|
|
33
|
-
selectionLabel: "Gmail
|
|
32
|
+
selectionLabel: "Gmail",
|
|
34
33
|
detailLabel: "Gmail",
|
|
35
34
|
docsPath: "/channels/gmail",
|
|
36
35
|
docsLabel: "gmail",
|
|
37
|
-
blurb: "
|
|
36
|
+
blurb: "Gmail integration via direct API or gog CLI.",
|
|
38
37
|
systemImage: "envelope",
|
|
39
38
|
order: 100,
|
|
40
39
|
showConfigured: true,
|
|
41
40
|
};
|
|
42
41
|
|
|
43
|
-
// Map to store active account contexts
|
|
42
|
+
// Map to store active account contexts and their clients
|
|
44
43
|
const activeAccounts = new Map<string, ChannelGatewayContext<ResolvedGmailAccount>>();
|
|
44
|
+
const activeClients = new Map<string, GmailClient>();
|
|
45
45
|
|
|
46
46
|
// Limit concurrent dispatches to avoid memory spikes
|
|
47
47
|
const dispatchSemaphore = new Semaphore(5);
|
|
@@ -93,6 +93,7 @@ function buildGmailMsgContext(
|
|
|
93
93
|
async function dispatchGmailMessage(
|
|
94
94
|
ctx: ChannelGatewayContext<ResolvedGmailAccount>,
|
|
95
95
|
msg: InboundMessage,
|
|
96
|
+
client: GmailClient,
|
|
96
97
|
) {
|
|
97
98
|
const { account, accountId, cfg, log } = ctx;
|
|
98
99
|
const runtime = getGmailRuntime();
|
|
@@ -101,7 +102,7 @@ async function dispatchGmailMessage(
|
|
|
101
102
|
await dispatchSemaphore.run(async () => {
|
|
102
103
|
try {
|
|
103
104
|
log?.info(`[gmail][${requestId}] Dispatching message ${msg.channelMessageId} from ${msg.sender.id}`);
|
|
104
|
-
|
|
105
|
+
|
|
105
106
|
// Build the dispatch context
|
|
106
107
|
const ctxPayload = buildGmailMsgContext(msg, account, cfg);
|
|
107
108
|
const gmailCfg = cfg.channels?.gmail as GmailConfig | undefined;
|
|
@@ -109,10 +110,10 @@ async function dispatchGmailMessage(
|
|
|
109
110
|
// Build reply dispatcher options using gateway's reply capability
|
|
110
111
|
const deliver = async (payload: { text: string }) => {
|
|
111
112
|
const originalSubject = msg.raw?.subject ||
|
|
112
|
-
msg.raw?.headers?.subject ||
|
|
113
|
+
msg.raw?.headers?.subject ||
|
|
113
114
|
msg.raw?.payload?.headers?.find((h: any) => h.name.toLowerCase() === "subject")?.value;
|
|
114
|
-
|
|
115
|
-
const replySubject = originalSubject
|
|
115
|
+
|
|
116
|
+
const replySubject = originalSubject
|
|
116
117
|
? (originalSubject.toLowerCase().startsWith("re:") ? originalSubject : `Re: ${originalSubject}`)
|
|
117
118
|
: "Re: ";
|
|
118
119
|
|
|
@@ -124,6 +125,7 @@ async function dispatchGmailMessage(
|
|
|
124
125
|
threadId: msg.threadId,
|
|
125
126
|
replyToId: msg.channelMessageId,
|
|
126
127
|
subject: replySubject,
|
|
128
|
+
client,
|
|
127
129
|
});
|
|
128
130
|
};
|
|
129
131
|
|
|
@@ -188,6 +190,16 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
188
190
|
historyId: { type: "string" },
|
|
189
191
|
delegate: { type: "string" },
|
|
190
192
|
archiveOnReply: { type: "boolean", default: true },
|
|
193
|
+
backend: { type: "string", enum: ["gog", "api"] },
|
|
194
|
+
oauth: {
|
|
195
|
+
type: "object",
|
|
196
|
+
properties: {
|
|
197
|
+
clientId: { type: "string" },
|
|
198
|
+
clientSecret: { type: "string" },
|
|
199
|
+
refreshToken: { type: "string" },
|
|
200
|
+
},
|
|
201
|
+
required: ["clientId", "clientSecret", "refreshToken"],
|
|
202
|
+
},
|
|
191
203
|
},
|
|
192
204
|
required: ["email"],
|
|
193
205
|
},
|
|
@@ -237,7 +249,12 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
237
249
|
outbound: {
|
|
238
250
|
deliveryMode: "gateway",
|
|
239
251
|
textChunkLimit: 8000,
|
|
240
|
-
sendText:
|
|
252
|
+
sendText: (ctx: any) => {
|
|
253
|
+
const account = resolveGmailAccount(ctx.cfg, ctx.accountId);
|
|
254
|
+
const emailKey = account.email?.toLowerCase();
|
|
255
|
+
const client = (emailKey && activeClients.get(emailKey)) || createGmailClient(account, ctx.cfg);
|
|
256
|
+
return sendGmailText({ ...ctx, client });
|
|
257
|
+
},
|
|
241
258
|
resolveTarget: ({ to, allowFrom }) => {
|
|
242
259
|
const trimmed = to?.trim() ?? "";
|
|
243
260
|
const normalized = normalizeGmailTarget(trimmed);
|
|
@@ -300,8 +317,10 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
300
317
|
"### Attachments",
|
|
301
318
|
"- **Location**: All attachments are stored in \`.attachments/{{threadId}}/\` relative to your workspace.",
|
|
302
319
|
"- **Auto-Download**: Files under 5MB are already there. The message text contains their paths.",
|
|
303
|
-
"- **Manual Download**: For larger files (listed with an ID), download them to that same folder
|
|
304
|
-
|
|
320
|
+
"- **Manual Download**: For larger files (listed with an ID), download them to that same folder.",
|
|
321
|
+
...(account.backend === "api"
|
|
322
|
+
? ["- The attachment download tool is available as part of the Gmail API client."]
|
|
323
|
+
: [`- Command: \`mkdir -p .attachments/{{threadId}} && gog gmail attachment <messageId> <attachmentId> --account ${account.email} --out .attachments/{{threadId}}/<filename>\``]),
|
|
305
324
|
];
|
|
306
325
|
},
|
|
307
326
|
},
|
|
@@ -310,15 +329,19 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
310
329
|
supportsAction: ({ action }: { action: string }) => action === "send",
|
|
311
330
|
handleAction: async (ctx: any) => {
|
|
312
331
|
if (ctx.action !== "send") return { ok: false, error: new Error(`Unsupported action: ${ctx.action}`) };
|
|
313
|
-
|
|
332
|
+
|
|
314
333
|
const { params, accountId, cfg, toolContext } = ctx;
|
|
334
|
+
const account = resolveGmailAccount(cfg, accountId);
|
|
335
|
+
const emailKey = account.email?.toLowerCase();
|
|
336
|
+
const client = (emailKey && activeClients.get(emailKey)) || createGmailClient(account, cfg);
|
|
337
|
+
|
|
315
338
|
const to = (params.target || params.to) as string;
|
|
316
339
|
const text = params.message as string;
|
|
317
|
-
|
|
340
|
+
|
|
318
341
|
const isThread = isGmailThreadId(to);
|
|
319
342
|
let subject = params.subject as string | undefined;
|
|
320
343
|
let replyToId: string | undefined;
|
|
321
|
-
|
|
344
|
+
|
|
322
345
|
if (isThread && toolContext?.currentThreadTs) {
|
|
323
346
|
replyToId = toolContext.currentThreadTs;
|
|
324
347
|
}
|
|
@@ -331,8 +354,9 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
331
354
|
threadId: isThread ? to : undefined,
|
|
332
355
|
replyToId,
|
|
333
356
|
subject,
|
|
357
|
+
client,
|
|
334
358
|
});
|
|
335
|
-
|
|
359
|
+
|
|
336
360
|
return { ok: true, content: [{ type: "text", text: "Message sent via Gmail." }] };
|
|
337
361
|
},
|
|
338
362
|
},
|
|
@@ -340,8 +364,12 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
340
364
|
startAccount: async (ctx) => {
|
|
341
365
|
ctx.log?.info(`[gmail] Account ${ctx.account.accountId} started`);
|
|
342
366
|
|
|
343
|
-
|
|
344
|
-
|
|
367
|
+
const client = createGmailClient(ctx.account, ctx.cfg);
|
|
368
|
+
const emailKey = ctx.account.email?.toLowerCase();
|
|
369
|
+
|
|
370
|
+
if (emailKey) {
|
|
371
|
+
activeAccounts.set(emailKey, ctx);
|
|
372
|
+
activeClients.set(emailKey, client);
|
|
345
373
|
}
|
|
346
374
|
|
|
347
375
|
ctx.setStatus({ accountId: ctx.accountId, running: true, connected: true });
|
|
@@ -355,11 +383,12 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
355
383
|
await monitorGmail({
|
|
356
384
|
account: ctx.account,
|
|
357
385
|
onMessage: async (msg) => {
|
|
358
|
-
await dispatchGmailMessage(ctx, msg);
|
|
386
|
+
await dispatchGmailMessage(ctx, msg, client);
|
|
359
387
|
},
|
|
360
388
|
signal,
|
|
361
389
|
log: ctx.log,
|
|
362
390
|
setStatus: ctx.setStatus,
|
|
391
|
+
client,
|
|
363
392
|
}).catch((err) => {
|
|
364
393
|
if (!signal.aborted) {
|
|
365
394
|
ctx.log?.error(`[gmail] Monitor error: ${String(err)}`);
|
|
@@ -367,8 +396,9 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
367
396
|
});
|
|
368
397
|
|
|
369
398
|
// Cleanup after monitor exits
|
|
370
|
-
if (
|
|
371
|
-
activeAccounts.delete(
|
|
399
|
+
if (emailKey) {
|
|
400
|
+
activeAccounts.delete(emailKey);
|
|
401
|
+
activeClients.delete(emailKey);
|
|
372
402
|
}
|
|
373
403
|
ctx.setStatus({ accountId: ctx.accountId, running: false, connected: false });
|
|
374
404
|
},
|
package/src/config.ts
CHANGED
|
@@ -16,6 +16,12 @@ export const GmailAccountSchema = z.object({
|
|
|
16
16
|
allowOutboundTo: z.array(z.string()).optional(), // Who we can SEND to (if not set, falls back to allowFrom)
|
|
17
17
|
threadReplyPolicy: z.enum(["open", "allowlist", "sender-only"]).optional(), // Default: "open" for backwards compat
|
|
18
18
|
archiveOnReply: z.boolean().optional(), // Archive thread after reply (default: true)
|
|
19
|
+
backend: z.enum(["gog", "api"]).optional(), // Gmail backend: gog CLI (default) or googleapis
|
|
20
|
+
oauth: z.object({
|
|
21
|
+
clientId: z.string(),
|
|
22
|
+
clientSecret: z.string(),
|
|
23
|
+
refreshToken: z.string(),
|
|
24
|
+
}).optional(), // OAuth2 credentials for API backend
|
|
19
25
|
});
|
|
20
26
|
|
|
21
27
|
export const GmailConfigSchema = z.object({
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ResolvedGmailAccount } from "./accounts.js";
|
|
2
|
+
import type { ThreadResponse } from "./quoting.js";
|
|
3
|
+
import type { GogSearchMessage } from "./inbound.js";
|
|
4
|
+
import { GogGmailClient } from "./gog-client.js";
|
|
5
|
+
import { ApiGmailClient } from "./api-client.js";
|
|
6
|
+
import { resolveOAuthCredentials, createOAuth2Client } from "./auth.js";
|
|
7
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
8
|
+
|
|
9
|
+
export interface GmailClient {
|
|
10
|
+
send(opts: {
|
|
11
|
+
account?: string;
|
|
12
|
+
to?: string;
|
|
13
|
+
subject: string;
|
|
14
|
+
textBody: string;
|
|
15
|
+
htmlBody?: string;
|
|
16
|
+
threadId?: string;
|
|
17
|
+
replyToMessageId?: string;
|
|
18
|
+
replyAll?: boolean;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
|
|
21
|
+
getThread(threadId: string, opts?: { full?: boolean }): Promise<ThreadResponse | null>;
|
|
22
|
+
getMessage(messageId: string): Promise<Record<string, unknown> | null>;
|
|
23
|
+
searchMessages(query: string, opts?: { maxResults?: number; includeBody?: boolean }): Promise<GogSearchMessage[]>;
|
|
24
|
+
searchThreads(query: string, opts?: { maxResults?: number }): Promise<Record<string, unknown> | null>;
|
|
25
|
+
modifyLabels(id: string, opts: { add?: string[]; remove?: string[] }): Promise<void>;
|
|
26
|
+
modifyThreadLabels(threadId: string, opts: { add?: string[]; remove?: string[] }): Promise<void>;
|
|
27
|
+
listLabels(): Promise<{ id: string; name: string }[]>;
|
|
28
|
+
createLabel(name: string): Promise<void>;
|
|
29
|
+
downloadAttachment(messageId: string, attachmentId: string, outPath: string): Promise<void>;
|
|
30
|
+
getSendAs(): Promise<{ displayName?: string; email: string; isPrimary?: boolean }[]>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createGmailClient(account: ResolvedGmailAccount, cfg?: OpenClawConfig): GmailClient {
|
|
34
|
+
if (account.backend === "api") {
|
|
35
|
+
if (!cfg) {
|
|
36
|
+
throw new Error(`API backend requires config to resolve OAuth credentials for ${account.email}`);
|
|
37
|
+
}
|
|
38
|
+
const creds = resolveOAuthCredentials(account.email, cfg);
|
|
39
|
+
if (!creds) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`No OAuth credentials found for ${account.email}. ` +
|
|
42
|
+
`Run onboarding or set oauth.clientId, oauth.clientSecret, and oauth.refreshToken in config.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const auth = createOAuth2Client(creds);
|
|
46
|
+
return new ApiGmailClient(auth);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return new GogGmailClient(account.email);
|
|
50
|
+
}
|