@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
|
@@ -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
|
|
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;
|