@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/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);
|
|
@@ -65,7 +65,7 @@ function buildGmailMsgContext(
|
|
|
65
65
|
CommandBody: msg.text,
|
|
66
66
|
From: msg.sender.id,
|
|
67
67
|
To: to,
|
|
68
|
-
SessionKey: `gmail:${account.email}:${msg.threadId}`,
|
|
68
|
+
SessionKey: `agent:main:gmail:${account.email}:${msg.threadId}`,
|
|
69
69
|
AccountId: msg.accountId,
|
|
70
70
|
ChatType: "direct",
|
|
71
71
|
ConversationLabel: threadLabel,
|
|
@@ -84,7 +84,7 @@ function buildGmailMsgContext(
|
|
|
84
84
|
MediaUrl: msg.mediaUrl,
|
|
85
85
|
CommandAuthorized: false,
|
|
86
86
|
OriginatingChannel: "gmail" as const,
|
|
87
|
-
OriginatingTo:
|
|
87
|
+
OriginatingTo: msg.threadId,
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
return ctx;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import type { GmailClient } from "./gmail-client.js";
|
|
5
|
+
import type { ThreadResponse } from "./quoting.js";
|
|
6
|
+
import { parseGogThreadOutput } from "./quoting.js";
|
|
7
|
+
import type { GogSearchMessage } from "./inbound.js";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
const GOG_TIMEOUT_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
interface CircuitState {
|
|
14
|
+
consecutiveFailures: number;
|
|
15
|
+
lastFailureAt: number;
|
|
16
|
+
backoffUntil: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const CIRCUIT_CONFIG = {
|
|
20
|
+
maxFailures: 3,
|
|
21
|
+
initialBackoffMs: 60_000,
|
|
22
|
+
maxBackoffMs: 15 * 60_000,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export class GogGmailClient implements GmailClient {
|
|
26
|
+
private circuit: CircuitState = { consecutiveFailures: 0, lastFailureAt: 0, backoffUntil: 0 };
|
|
27
|
+
|
|
28
|
+
constructor(private accountEmail: string) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if the gog CLI is available on PATH.
|
|
32
|
+
*/
|
|
33
|
+
static async checkExists(): Promise<boolean> {
|
|
34
|
+
try {
|
|
35
|
+
await execFileAsync("gog", ["--version"]);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Write operation via spawn() — fire-and-forget style with retries.
|
|
44
|
+
* Mirrors outbound.ts:17-44.
|
|
45
|
+
*/
|
|
46
|
+
private async execGog(args: string[], retries = 3): Promise<void> {
|
|
47
|
+
for (let i = 0; i < retries; i++) {
|
|
48
|
+
try {
|
|
49
|
+
await new Promise<void>((resolve, reject) => {
|
|
50
|
+
const proc = spawn("gog", args, { stdio: "pipe" });
|
|
51
|
+
let err = "";
|
|
52
|
+
let out = "";
|
|
53
|
+
proc.stderr.on("data", (d) => (err += d.toString()));
|
|
54
|
+
proc.stdout.on("data", (d) => (out += d.toString()));
|
|
55
|
+
proc.on("error", (e) => reject(new Error(`gog failed to spawn: ${e.message}`)));
|
|
56
|
+
proc.on("close", (code) => {
|
|
57
|
+
if (code === 0) resolve();
|
|
58
|
+
else reject(new Error(`gog failed (code ${code}): ${err || out}`));
|
|
59
|
+
});
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
proc.kill();
|
|
62
|
+
reject(new Error("gog timed out after 30s"));
|
|
63
|
+
}, GOG_TIMEOUT_MS);
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (i === retries - 1) throw err;
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read operation via execFileAsync() with circuit breaker.
|
|
75
|
+
* Mirrors monitor.ts:70-125.
|
|
76
|
+
*/
|
|
77
|
+
private async runGog(args: string[], retries = 3): Promise<Record<string, unknown> | null> {
|
|
78
|
+
const circuit = this.circuit;
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
|
|
81
|
+
if (now < circuit.backoffUntil) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Circuit breaker active for ${this.accountEmail}. Backing off until ${new Date(circuit.backoffUntil).toISOString()}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const allArgs = ["--json", "--account", this.accountEmail, ...args];
|
|
88
|
+
let lastErr: any;
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < retries; i++) {
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await execFileAsync("gog", allArgs, {
|
|
93
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
94
|
+
timeout: GOG_TIMEOUT_MS,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Success — reset circuit
|
|
98
|
+
circuit.consecutiveFailures = 0;
|
|
99
|
+
circuit.backoffUntil = 0;
|
|
100
|
+
|
|
101
|
+
if (!stdout.trim()) return null;
|
|
102
|
+
return JSON.parse(stdout) as Record<string, unknown>;
|
|
103
|
+
} catch (err: unknown) {
|
|
104
|
+
lastErr = err;
|
|
105
|
+
const msg = String(err);
|
|
106
|
+
|
|
107
|
+
// 404 → not found, not a failure
|
|
108
|
+
if (msg.includes("404")) return null;
|
|
109
|
+
|
|
110
|
+
// Trip circuit breaker on auth / server errors
|
|
111
|
+
if (msg.includes("403") || msg.includes("invalid_grant") || msg.includes("ETIMEDOUT") || msg.includes("500")) {
|
|
112
|
+
circuit.consecutiveFailures++;
|
|
113
|
+
circuit.lastFailureAt = Date.now();
|
|
114
|
+
|
|
115
|
+
if (circuit.consecutiveFailures >= CIRCUIT_CONFIG.maxFailures) {
|
|
116
|
+
const backoff = Math.min(
|
|
117
|
+
CIRCUIT_CONFIG.initialBackoffMs * Math.pow(2, circuit.consecutiveFailures - CIRCUIT_CONFIG.maxFailures),
|
|
118
|
+
CIRCUIT_CONFIG.maxBackoffMs,
|
|
119
|
+
);
|
|
120
|
+
circuit.backoffUntil = Date.now() + backoff;
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (i < retries - 1) {
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * (i + 1)));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const error = lastErr as { stderr?: string; message?: string };
|
|
132
|
+
throw new Error(`gog failed: ${error.stderr || error.message || String(lastErr)}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── GmailClient interface ──────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
async send(opts: {
|
|
138
|
+
account?: string;
|
|
139
|
+
to?: string;
|
|
140
|
+
subject: string;
|
|
141
|
+
textBody: string;
|
|
142
|
+
htmlBody?: string;
|
|
143
|
+
threadId?: string;
|
|
144
|
+
replyToMessageId?: string;
|
|
145
|
+
replyAll?: boolean;
|
|
146
|
+
}): Promise<void> {
|
|
147
|
+
const args = ["gmail", "send"];
|
|
148
|
+
if (this.accountEmail) args.push("--account", this.accountEmail);
|
|
149
|
+
if (opts.to) args.push("--to", opts.to);
|
|
150
|
+
if (opts.subject) args.push("--subject", opts.subject);
|
|
151
|
+
if (opts.threadId) args.push("--thread-id", opts.threadId);
|
|
152
|
+
if (opts.replyToMessageId) args.push("--reply-to-message-id", opts.replyToMessageId);
|
|
153
|
+
if (opts.replyAll) args.push("--reply-all");
|
|
154
|
+
if (opts.htmlBody) args.push("--body-html", opts.htmlBody);
|
|
155
|
+
args.push("--body", opts.textBody);
|
|
156
|
+
await this.execGog(args);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async getThread(threadId: string, opts?: { full?: boolean }): Promise<ThreadResponse | null> {
|
|
160
|
+
const args = ["gmail", "thread", "get", threadId];
|
|
161
|
+
if (opts?.full !== false) args.push("--full");
|
|
162
|
+
const data = await this.runGog(args);
|
|
163
|
+
if (!data) return null;
|
|
164
|
+
return parseGogThreadOutput(data);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async getMessage(messageId: string): Promise<Record<string, unknown> | null> {
|
|
168
|
+
return this.runGog(["gmail", "get", messageId]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async searchMessages(
|
|
172
|
+
query: string,
|
|
173
|
+
opts?: { maxResults?: number; includeBody?: boolean },
|
|
174
|
+
): Promise<GogSearchMessage[]> {
|
|
175
|
+
const args = ["gmail", "messages", "search", query];
|
|
176
|
+
if (opts?.includeBody !== false) args.push("--include-body");
|
|
177
|
+
args.push("--max", String(opts?.maxResults ?? 50));
|
|
178
|
+
const res = await this.runGog(args);
|
|
179
|
+
if (!res || !Array.isArray((res as any).messages)) return [];
|
|
180
|
+
return (res as any).messages as GogSearchMessage[];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async searchThreads(query: string, opts?: { maxResults?: number }): Promise<Record<string, unknown> | null> {
|
|
184
|
+
const args = ["gmail", "search", query];
|
|
185
|
+
args.push("--max", String(opts?.maxResults ?? 50));
|
|
186
|
+
return this.runGog(args);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async modifyLabels(id: string, opts: { add?: string[]; remove?: string[] }): Promise<void> {
|
|
190
|
+
const args = ["gmail", "labels", "modify", id];
|
|
191
|
+
if (opts.add) for (const l of opts.add) args.push("--add", l);
|
|
192
|
+
if (opts.remove) for (const l of opts.remove) args.push("--remove", l);
|
|
193
|
+
await this.runGog(args);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async modifyThreadLabels(threadId: string, opts: { add?: string[]; remove?: string[] }): Promise<void> {
|
|
197
|
+
const args = ["gmail", "thread", "modify", threadId];
|
|
198
|
+
if (opts.add) for (const l of opts.add) args.push("--add", l);
|
|
199
|
+
if (opts.remove) for (const l of opts.remove) args.push("--remove", l);
|
|
200
|
+
await this.runGog(args);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async listLabels(): Promise<{ id: string; name: string }[]> {
|
|
204
|
+
const res = await this.runGog(["gmail", "labels", "list"]);
|
|
205
|
+
return ((res as any)?.labels || []) as { id: string; name: string }[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async createLabel(name: string): Promise<void> {
|
|
209
|
+
await this.runGog(["gmail", "labels", "create", name]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async downloadAttachment(messageId: string, attachmentId: string, outPath: string): Promise<void> {
|
|
213
|
+
// No circuit breaker for attachment downloads
|
|
214
|
+
await execFileAsync("gog", [
|
|
215
|
+
"gmail",
|
|
216
|
+
"attachment",
|
|
217
|
+
messageId,
|
|
218
|
+
attachmentId,
|
|
219
|
+
"--account",
|
|
220
|
+
this.accountEmail,
|
|
221
|
+
"--out",
|
|
222
|
+
outPath,
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async getSendAs(): Promise<{ displayName?: string; email: string; isPrimary?: boolean }[]> {
|
|
227
|
+
const res = await this.runGog(["gmail", "settings", "sendas", "list"]);
|
|
228
|
+
return ((res as any)?.sendAs || []) as { displayName?: string; email: string; isPrimary?: boolean }[];
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/mime.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import MailComposer from "nodemailer/lib/mail-composer";
|
|
2
|
+
|
|
3
|
+
export interface MimeMessageOpts {
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
cc?: string;
|
|
7
|
+
subject: string;
|
|
8
|
+
text: string;
|
|
9
|
+
html?: string;
|
|
10
|
+
inReplyTo?: string;
|
|
11
|
+
references?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build an RFC 2822 MIME message using nodemailer's MailComposer.
|
|
16
|
+
* Returns a Buffer suitable for base64url encoding and passing to
|
|
17
|
+
* gmail.users.messages.send.
|
|
18
|
+
*/
|
|
19
|
+
export function buildMimeMessage(opts: MimeMessageOpts): Promise<Buffer> {
|
|
20
|
+
const mail = new MailComposer({
|
|
21
|
+
from: opts.from,
|
|
22
|
+
to: opts.to,
|
|
23
|
+
cc: opts.cc,
|
|
24
|
+
subject: opts.subject,
|
|
25
|
+
text: opts.text,
|
|
26
|
+
html: opts.html,
|
|
27
|
+
inReplyTo: opts.inReplyTo,
|
|
28
|
+
references: opts.references,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
mail.compile().build((err: Error | null, message: Buffer) => {
|
|
33
|
+
if (err) reject(err);
|
|
34
|
+
else resolve(message);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|