@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.
@@ -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
+ }
package/src/monitor.ts CHANGED
@@ -1,17 +1,14 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import fs from "node:fs/promises";
4
2
  import path from "node:path";
5
3
  import os from "node:os";
6
4
  import lockfile from "proper-lockfile";
7
5
  import type { ChannelLogSink, InboundMessage } from "openclaw/plugin-sdk";
8
6
  import type { ResolvedGmailAccount } from "./accounts.js";
9
- import { loadHistoryId, saveHistoryId } from "./history-store.js";
10
7
  import { parseInboundGmail, parseSearchGmail, type GogPayload, type GogSearchMessage } from "./inbound.js";
11
- import { extractAttachments, type GmailAttachment } from "./attachments.js";
8
+ import { extractAttachments } from "./attachments.js";
12
9
  import { isAllowed } from "./normalize.js";
13
-
14
- const execFileAsync = promisify(execFile);
10
+ import type { GmailClient } from "./gmail-client.js";
11
+ import { GogGmailClient } from "./gog-client.js";
15
12
 
16
13
  // Polling interval: Default 60s, override via env for testing
17
14
  const DEFAULT_POLL_INTERVAL = 60_000;
@@ -29,118 +26,28 @@ const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((resolve)
29
26
  }, { once: true });
30
27
  });
31
28
 
32
- const GOG_TIMEOUT_MS = 30_000;
33
- const SYNC_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
34
-
35
29
  // Local deduplication cache to prevent re-dispatching messages before Gmail updates labels
36
30
  const dispatchedMessageIds = new Set<string>();
37
31
  // Clear cache periodically to prevent memory growth (every hour)
38
32
  setInterval(() => dispatchedMessageIds.clear(), 60 * 60 * 1000).unref();
39
33
 
40
- interface CircuitState {
41
- consecutiveFailures: number;
42
- lastFailureAt: number;
43
- backoffUntil: number;
44
- }
45
-
46
- const CIRCUIT_CONFIG = {
47
- maxFailures: 3,
48
- initialBackoffMs: 60_000, // 1 minute
49
- maxBackoffMs: 15 * 60_000, // 15 minutes
50
- };
51
-
52
- const circuitStates = new Map<string, CircuitState>();
53
-
54
- function getCircuit(email: string): CircuitState {
55
- if (!circuitStates.has(email)) {
56
- circuitStates.set(email, { consecutiveFailures: 0, lastFailureAt: 0, backoffUntil: 0 });
57
- }
58
- return circuitStates.get(email)!;
59
- }
60
-
61
- async function checkGogExists(): Promise<boolean> {
62
- try {
63
- await execFileAsync("gog", ["--version"]);
64
- return true;
65
- } catch {
66
- return false;
67
- }
68
- }
69
-
70
- async function runGog(args: string[], accountEmail: string, retries = 3): Promise<Record<string, unknown> | null> {
71
- const circuit = getCircuit(accountEmail);
72
- const now = Date.now();
73
-
74
- if (now < circuit.backoffUntil) {
75
- throw new Error(`Circuit breaker active for ${accountEmail}. Backing off until ${new Date(circuit.backoffUntil).toISOString()}`);
76
- }
77
-
78
- const allArgs = ["--json", "--account", accountEmail, ...args];
79
- let lastErr: any;
80
-
81
- for (let i = 0; i < retries; i++) {
82
- try {
83
- const { stdout } = await execFileAsync("gog", allArgs, {
84
- maxBuffer: 10 * 1024 * 1024,
85
- timeout: GOG_TIMEOUT_MS,
86
- });
87
-
88
- // Success - reset circuit
89
- circuit.consecutiveFailures = 0;
90
- circuit.backoffUntil = 0;
91
-
92
- if (!stdout.trim()) return null;
93
- return JSON.parse(stdout) as Record<string, unknown>;
94
- } catch (err: unknown) {
95
- lastErr = err;
96
- const msg = String(err);
97
-
98
- // Don't retry/backoff on certain errors (e.g. 404 means no messages, not a failure)
99
- if (msg.includes("404")) {
100
- return null;
101
- }
102
-
103
- if (msg.includes("403") || msg.includes("invalid_grant") || msg.includes("ETIMEDOUT") || msg.includes("500")) {
104
- circuit.consecutiveFailures++;
105
- circuit.lastFailureAt = Date.now();
106
-
107
- if (circuit.consecutiveFailures >= CIRCUIT_CONFIG.maxFailures) {
108
- const backoff = Math.min(
109
- CIRCUIT_CONFIG.initialBackoffMs * Math.pow(2, circuit.consecutiveFailures - CIRCUIT_CONFIG.maxFailures),
110
- CIRCUIT_CONFIG.maxBackoffMs
111
- );
112
- circuit.backoffUntil = Date.now() + backoff;
113
- }
114
- break;
115
- }
116
-
117
- if (i < retries - 1) {
118
- await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
119
- }
120
- }
121
- }
122
-
123
- const error = lastErr as { stderr?: string; message?: string };
124
- throw new Error(`gog failed: ${error.stderr || error.message || String(lastErr)}`);
125
- }
126
-
127
- export async function quarantineMessage(id: string, accountEmail: string, log: ChannelLogSink) {
34
+ export async function quarantineMessage(id: string, log: ChannelLogSink, client: GmailClient) {
128
35
  try {
129
36
  // Add 'not-allow-listed', remove 'INBOX', leave UNREAD
130
- await runGog(["gmail", "labels", "modify", id, "--add", "not-allow-listed", "--remove", "INBOX"], accountEmail);
131
- log.info(`Quarantined message ${id} from disallowed sender (moved to not-allow-listed, removed from INBOX)`);
37
+ await client.modifyLabels(id, { add: [QUARANTINE_LABEL], remove: ["INBOX"] });
38
+ log.info(`Quarantined message ${id} from disallowed sender (moved to ${QUARANTINE_LABEL}, removed from INBOX)`);
132
39
  } catch (err) {
133
40
  log.error(`Failed to quarantine message ${id}: ${String(err)}`);
134
41
  }
135
42
  }
136
43
 
137
- async function markAsRead(id: string, threadId: string | undefined, accountEmail: string, log: ChannelLogSink) {
44
+ async function markAsRead(id: string, threadId: string | undefined, log: ChannelLogSink, client: GmailClient) {
138
45
  try {
139
46
  // Prefer thread-level modification as it's more robust in Gmail for label propagation
140
47
  if (threadId) {
141
- await runGog(["gmail", "thread", "modify", threadId, "--remove", "UNREAD"], accountEmail);
48
+ await client.modifyThreadLabels(threadId, { remove: ["UNREAD"] });
142
49
  } else {
143
- await runGog(["gmail", "labels", "modify", id, "--remove", "UNREAD"], accountEmail);
50
+ await client.modifyLabels(id, { remove: ["UNREAD"] });
144
51
  }
145
52
  } catch (err) {
146
53
  log.error(`Failed to mark ${id} as read: ${String(err)}`);
@@ -186,7 +93,7 @@ async function pruneGmailSessions(account: ResolvedGmailAccount, log: ChannelLog
186
93
  if (entry.updatedAt && now - entry.updatedAt > ttlMs) {
187
94
  // Found an expired session
188
95
  const threadId = key.split(":").pop();
189
-
96
+
190
97
  // Delete associated attachments directory if it exists
191
98
  if (threadId) {
192
99
  const threadAttachmentsDir = path.join(attachmentsDir, threadId);
@@ -223,10 +130,11 @@ async function fetchMessageDetails(
223
130
  id: string,
224
131
  account: ResolvedGmailAccount,
225
132
  log: ChannelLogSink,
133
+ client: GmailClient,
226
134
  ignoreLabels = false
227
135
  ): Promise<InboundMessage | null> {
228
136
  try {
229
- const res = await runGog(["gmail", "get", id], account.email);
137
+ const res = await client.getMessage(id);
230
138
  if (!res) return null;
231
139
 
232
140
  const message = (res.message || res) as Record<string, unknown>;
@@ -250,12 +158,13 @@ async function fetchMessageDetails(
250
158
  }
251
159
 
252
160
  async function downloadAttachmentsIfSmall(
253
- msg: InboundMessage,
254
- account: ResolvedGmailAccount,
255
- log: ChannelLogSink
161
+ msg: InboundMessage,
162
+ account: ResolvedGmailAccount,
163
+ log: ChannelLogSink,
164
+ client: GmailClient,
256
165
  ): Promise<string[]> {
257
166
  if (!msg.raw || !msg.raw.payload) return [];
258
-
167
+
259
168
  const attachments = extractAttachments(msg.raw.payload);
260
169
  const downloaded: string[] = [];
261
170
 
@@ -269,18 +178,11 @@ async function downloadAttachmentsIfSmall(
269
178
  const ext = path.extname(att.filename) || "";
270
179
  const safeName = path.basename(att.filename, ext).replace(/[^a-z0-9]/gi, '_') + ext;
271
180
  const outPath = path.join(threadAttachmentsDir, safeName);
272
-
181
+
273
182
  await fs.mkdir(threadAttachmentsDir, { recursive: true });
274
-
275
- // Use gog to download
276
- await execFileAsync("gog", [
277
- "gmail", "attachment",
278
- msg.channelMessageId,
279
- att.attachmentId,
280
- "--account", account.email,
281
- "--out", outPath
282
- ]);
283
-
183
+
184
+ await client.downloadAttachment(msg.channelMessageId, att.attachmentId, outPath);
185
+
284
186
  downloaded.push(outPath);
285
187
  log.info(`Auto-downloaded attachment ${att.filename} to ${outPath}`);
286
188
  } catch (err) {
@@ -295,34 +197,29 @@ async function performFullSync(
295
197
  account: ResolvedGmailAccount,
296
198
  onMessage: (msg: InboundMessage) => Promise<void>,
297
199
  signal: AbortSignal,
298
- log: ChannelLogSink
200
+ log: ChannelLogSink,
201
+ client: GmailClient,
299
202
  ): Promise<string | null> {
300
203
  // Use label:INBOX label:UNREAD for the most reliable bot inbox pattern
301
- // We explicitly ask for JSON output and include-body
302
- const searchResult = await runGog([
303
- "gmail", "messages", "search",
304
- "label:INBOX label:UNREAD",
305
- "--include-body",
306
- "--max", "50"
307
- ], account.email);
308
-
309
- if (!searchResult || !Array.isArray((searchResult as any).messages)) {
310
- return null;
311
- }
204
+ const rawMessages = await client.searchMessages("label:INBOX label:UNREAD", {
205
+ maxResults: 50,
206
+ includeBody: true,
207
+ });
208
+
209
+ if (rawMessages.length === 0) return null;
312
210
 
313
- const rawMessages = (searchResult as any).messages as GogSearchMessage[];
314
211
  const inboundMessages: InboundMessage[] = [];
315
212
 
316
213
  for (const raw of rawMessages) {
317
214
  if (signal.aborted) break;
318
-
215
+
319
216
  // Parse the simplified search result
320
217
  const msg = parseSearchGmail(raw, account.accountId, account.email);
321
-
218
+
322
219
  if (msg) {
323
220
  if (!isAllowed(msg.sender.id, account.allowFrom || [])) {
324
221
  log.warn(`Quarantining email from non-whitelisted sender: ${msg.sender.id}`);
325
- await quarantineMessage(msg.channelMessageId, account.email, log);
222
+ await quarantineMessage(msg.channelMessageId, log, client);
326
223
  continue;
327
224
  }
328
225
  inboundMessages.push(msg);
@@ -338,14 +235,14 @@ async function performFullSync(
338
235
 
339
236
  for (const [threadId, messages] of threads) {
340
237
  if (signal.aborted) break;
341
-
238
+
342
239
  // Filter out messages we've already dispatched in this session
343
240
  const newMessages = messages.filter(msg => !dispatchedMessageIds.has(msg.channelMessageId));
344
241
  if (newMessages.length === 0) continue;
345
242
 
346
243
  log.info(`[Sync] Processing thread ${threadId} with ${newMessages.length} new messages`);
347
244
  newMessages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
348
-
245
+
349
246
  for (const msg of newMessages) {
350
247
  if (signal.aborted) break;
351
248
 
@@ -353,23 +250,23 @@ async function performFullSync(
353
250
  dispatchedMessageIds.add(msg.channelMessageId);
354
251
 
355
252
  // To get attachments, we need the full message details (search --include-body only gives text)
356
- const fullMsg = await fetchMessageDetails(msg.channelMessageId, account, log, true);
253
+ const fullMsg = await fetchMessageDetails(msg.channelMessageId, account, log, client, true);
357
254
  const msgToDispatch = fullMsg || msg;
358
255
 
359
256
  try {
360
257
  // Auto-download small attachments
361
258
  if (fullMsg) {
362
- const downloadedPaths = await downloadAttachmentsIfSmall(fullMsg, account, log);
259
+ const downloadedPaths = await downloadAttachmentsIfSmall(fullMsg, account, log, client);
363
260
  if (downloadedPaths.length > 0) {
364
- msgToDispatch.text += "\n\n### Auto-downloaded Files\n" +
261
+ msgToDispatch.text += "\n\n### Auto-downloaded Files\n" +
365
262
  downloadedPaths.map(p => `- \`${p}\``).join("\n");
366
263
  }
367
264
  }
368
265
 
369
266
  await onMessage(msgToDispatch);
370
-
267
+
371
268
  // CRITICAL: Only mark as read after successful dispatch
372
- await markAsRead(msg.channelMessageId, msg.threadId, account.email, log);
269
+ await markAsRead(msg.channelMessageId, msg.threadId, log, client);
373
270
  } catch (err) {
374
271
  log.error(`Failed to dispatch message ${msg.channelMessageId}, leaving as UNREAD: ${String(err)}`);
375
272
  // Remove from dedupe so it's retried next tick
@@ -379,27 +276,23 @@ async function performFullSync(
379
276
  }
380
277
 
381
278
  // Get latest history ID to resume polling
382
- const latest = await runGog(["gmail", "search", "label:INBOX", "--max", "1"], account.email);
279
+ const latest = await client.searchThreads("label:INBOX", { maxResults: 1 });
383
280
  if ((latest as any)?.threads?.[0]) {
384
- // We still need a separate fetch for historyId as search result (thread list) might not have it
385
- // But this is just 1 call.
386
- const thread = await runGog(["gmail", "get", (latest as any).threads[0].id], account.email);
281
+ const thread = await client.getMessage((latest as any).threads[0].id);
387
282
  const nextId = (thread as any)?.message?.historyId || (thread as any)?.historyId || null;
388
283
  return nextId;
389
284
  }
390
285
  return null;
391
286
  }
392
287
 
393
- async function ensureQuarantineLabel(accountEmail: string, log: ChannelLogSink) {
288
+ async function ensureQuarantineLabel(log: ChannelLogSink, client: GmailClient) {
394
289
  try {
395
- const res = await runGog(["gmail", "labels", "list"], accountEmail);
396
- // res.labels is likely the array from the JSON output
397
- const labels = (res as any)?.labels || [];
398
- const exists = labels.some((l: any) => l.name === QUARANTINE_LABEL);
290
+ const labels = await client.listLabels();
291
+ const exists = labels.some((l) => l.name === QUARANTINE_LABEL);
399
292
 
400
293
  if (!exists) {
401
294
  log.info(`Creating quarantine label '${QUARANTINE_LABEL}'...`);
402
- await runGog(["gmail", "labels", "create", QUARANTINE_LABEL], accountEmail);
295
+ await client.createLabel(QUARANTINE_LABEL);
403
296
  }
404
297
  } catch (err) {
405
298
  // If this fails, quarantine attempts will also fail, but we don't block startup
@@ -413,11 +306,12 @@ export async function monitorGmail(params: {
413
306
  signal: AbortSignal;
414
307
  log: ChannelLogSink;
415
308
  setStatus: (status: any) => void;
309
+ client: GmailClient;
416
310
  }) {
417
- const { account, onMessage, signal, log, setStatus } = params;
418
-
419
- // Doctor check
420
- if (!(await checkGogExists())) {
311
+ const { account, onMessage, signal, log, setStatus, client } = params;
312
+
313
+ // Doctor check — only require gog CLI for the gog backend
314
+ if (account.backend !== "api" && !(await GogGmailClient.checkExists())) {
421
315
  log.error("gog CLI not found in PATH. Gmail channel disabled.");
422
316
  setStatus({ accountId: account.accountId, running: false, connected: false, error: "gog CLI missing" });
423
317
  return;
@@ -426,7 +320,7 @@ export async function monitorGmail(params: {
426
320
  log.info(`Starting monitor for ${account.email}`);
427
321
 
428
322
  // Ensure quarantine label exists
429
- await ensureQuarantineLabel(account.email, log);
323
+ await ensureQuarantineLabel(log, client);
430
324
 
431
325
  // Prune on start
432
326
  await pruneGmailSessions(account, log);
@@ -457,7 +351,7 @@ export async function monitorGmail(params: {
457
351
  isSyncing = true;
458
352
  try {
459
353
  log.debug("Performing full search sync...");
460
- await performFullSync(account, onMessage, signal, log);
354
+ await performFullSync(account, onMessage, signal, log, client);
461
355
  setStatus({ accountId: account.accountId, running: true, connected: true, lastError: undefined });
462
356
  } finally {
463
357
  isSyncing = false;