@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/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 { parseInboundGmail, type GogPayload } from "./inbound.js";
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 (gog)",
32
+ selectionLabel: "Gmail",
34
33
  detailLabel: "Gmail",
35
34
  docsPath: "/channels/gmail",
36
35
  docsLabel: "gmail",
37
- blurb: "Uses gog for secure Gmail access.",
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: sendGmailText,
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
- `- Command: \`mkdir -p .attachments/{{threadId}} && gog gmail attachment <messageId> <attachmentId> --account ${account.email} --out .attachments/{{threadId}}/<filename>\``,
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
- if (ctx.account.email) {
344
- activeAccounts.set(ctx.account.email.toLowerCase(), ctx);
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 (ctx.account.email) {
371
- activeAccounts.delete(ctx.account.email.toLowerCase());
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
+ }