@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.
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Thread recipient validation for Gmail outbound.
3
- *
3
+ *
4
4
  * Validates that thread reply recipients are permitted by allowOutboundTo.
5
5
  */
6
6
 
7
- import { spawn } from "node:child_process";
7
+ import type { GmailClient } from "./gmail-client.js";
8
8
 
9
9
  export interface ThreadParticipant {
10
10
  email: string;
@@ -16,94 +16,9 @@ export interface ThreadData {
16
16
  originalSender: string | null;
17
17
  }
18
18
 
19
- /**
20
- * Fetch thread data (participants and original sender) in a single call.
21
- */
22
- export async function fetchThreadData(
23
- threadId: string,
24
- accountEmail: string
25
- ): Promise<ThreadData> {
26
- return new Promise((resolve, reject) => {
27
- const args = ["gmail", "thread", "get", threadId, "--json", "--account", accountEmail];
28
- const proc = spawn("gog", args, { stdio: "pipe" });
29
- let stdout = "";
30
- let stderr = "";
31
- let settled = false;
32
-
33
- const timeout = setTimeout(() => {
34
- if (!settled) {
35
- settled = true;
36
- proc.kill();
37
- reject(new Error("Timeout fetching thread data"));
38
- }
39
- }, 10000);
40
-
41
- const cleanup = () => {
42
- clearTimeout(timeout);
43
- };
44
-
45
- proc.stdout.on("data", (d) => (stdout += d.toString()));
46
- proc.stderr.on("data", (d) => (stderr += d.toString()));
47
-
48
- proc.on("close", (code) => {
49
- if (settled) return;
50
- settled = true;
51
- cleanup();
52
-
53
- if (code !== 0) {
54
- reject(new Error(`Failed to fetch thread: ${stderr}`));
55
- return;
56
- }
57
-
58
- try {
59
- const data = JSON.parse(stdout);
60
- const messages = data.thread?.messages || [];
61
- const participants = new Map<string, ThreadParticipant>();
62
- let originalSender: string | null = null;
63
-
64
- for (let i = 0; i < messages.length; i++) {
65
- const msg = messages[i];
66
- const headers = msg.payload?.headers || [];
67
-
68
- for (const header of headers) {
69
- const name = header.name.toLowerCase();
70
- if (["from", "to", "cc"].includes(name)) {
71
- const addresses = parseEmailAddresses(header.value);
72
- for (const addr of addresses) {
73
- if (addr.email.toLowerCase() !== accountEmail.toLowerCase()) {
74
- participants.set(addr.email.toLowerCase(), addr);
75
- }
76
-
77
- // Capture original sender from first message's From header
78
- if (i === 0 && name === "from" && !originalSender) {
79
- originalSender = addr.email;
80
- }
81
- }
82
- }
83
- }
84
- }
85
-
86
- resolve({
87
- participants: Array.from(participants.values()),
88
- originalSender,
89
- });
90
- } catch (e) {
91
- reject(new Error(`Failed to parse thread: ${e}`));
92
- }
93
- });
94
-
95
- proc.on("error", (e) => {
96
- if (settled) return;
97
- settled = true;
98
- cleanup();
99
- reject(new Error(`Failed to spawn gog: ${e.message}`));
100
- });
101
- });
102
- }
103
-
104
19
  /**
105
20
  * Parse email addresses from a header value like "Name <email>, Other <email2>"
106
- *
21
+ *
107
22
  * Handles:
108
23
  * - Simple: email@example.com
109
24
  * - Named: Name <email@example.com>
@@ -113,20 +28,24 @@ export async function fetchThreadData(
113
28
  export function parseEmailAddresses(value: string): ThreadParticipant[] {
114
29
  const results: ThreadParticipant[] = [];
115
30
  if (!value || typeof value !== "string") return results;
116
-
31
+
117
32
  // Split by comma, but respect quoted strings
118
33
  // This regex splits on commas that are NOT inside quotes
119
34
  const parts = value.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/);
120
-
35
+
121
36
  for (const part of parts) {
122
37
  const trimmed = part.trim();
123
38
  if (!trimmed) continue;
124
-
39
+
125
40
  // Try to match "Name" <email> or Name <email>
126
- const match = trimmed.match(/^(?:"?([^"<]*)"?\s*)?<([^>]+)>$/);
127
-
41
+ // First pattern: quoted name with possible escaped quotes inside
42
+ // Second pattern: unquoted name
43
+ const match =
44
+ trimmed.match(/^"((?:[^"\\]|\\.)*?)"\s*<([^>]+)>$/) ||
45
+ trimmed.match(/^(?:([^"<]*)\s+)?<([^>]+)>$/);
46
+
128
47
  if (match) {
129
- const name = match[1]?.trim().replace(/^"|"$/g, "");
48
+ const name = match[1]?.trim().replace(/\\"/g, '"');
130
49
  const email = match[2].trim().toLowerCase();
131
50
  if (email.includes("@")) {
132
51
  results.push({ name: name || undefined, email });
@@ -136,13 +55,13 @@ export function parseEmailAddresses(value: string): ThreadParticipant[] {
136
55
  results.push({ email: trimmed.toLowerCase() });
137
56
  }
138
57
  }
139
-
58
+
140
59
  return results;
141
60
  }
142
61
 
143
62
  /**
144
63
  * Check if an email is allowed by the allowlist.
145
- *
64
+ *
146
65
  * Rules:
147
66
  * - Empty list = no restriction (returns true)
148
67
  * - "*" in list = allow all
@@ -153,10 +72,10 @@ export function isEmailAllowed(email: string, allowList: string[]): boolean {
153
72
  // Empty list = no restriction
154
73
  if (allowList.length === 0) return true;
155
74
  if (allowList.includes("*")) return true;
156
-
75
+
157
76
  if (!email) return false;
158
77
  const normalized = email.toLowerCase();
159
-
78
+
160
79
  return allowList.some((entry) => {
161
80
  const e = entry.toLowerCase().trim();
162
81
  if (!e) return false;
@@ -173,9 +92,45 @@ export interface ValidationResult {
173
92
  reason?: string;
174
93
  }
175
94
 
95
+ /**
96
+ * Extract ThreadData (participants + original sender) from thread messages.
97
+ * Replaces the old fetchThreadData() that spawned gog directly.
98
+ */
99
+ function extractThreadData(
100
+ messages: { from: string; to?: string; cc?: string }[],
101
+ accountEmail: string,
102
+ ): ThreadData {
103
+ const participants = new Map<string, ThreadParticipant>();
104
+ let originalSender: string | null = null;
105
+
106
+ for (let i = 0; i < messages.length; i++) {
107
+ const msg = messages[i];
108
+ const fields: string[] = [msg.from, msg.to, msg.cc].filter(Boolean) as string[];
109
+
110
+ for (const field of fields) {
111
+ const addresses = parseEmailAddresses(field);
112
+ for (const addr of addresses) {
113
+ if (addr.email.toLowerCase() !== accountEmail.toLowerCase()) {
114
+ participants.set(addr.email.toLowerCase(), addr);
115
+ }
116
+
117
+ // Capture original sender from first message's From field
118
+ if (i === 0 && field === msg.from && !originalSender) {
119
+ originalSender = addr.email;
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ return {
126
+ participants: Array.from(participants.values()),
127
+ originalSender,
128
+ };
129
+ }
130
+
176
131
  /**
177
132
  * Validate thread recipients against policy.
178
- *
133
+ *
179
134
  * Policies:
180
135
  * - "open": Allow replies to anyone (default, backwards compatible)
181
136
  * - "allowlist": All recipients must be in allowOutboundTo
@@ -185,7 +140,8 @@ export async function validateThreadReply(
185
140
  threadId: string,
186
141
  accountEmail: string,
187
142
  allowOutboundTo: string[],
188
- policy: "open" | "allowlist" | "sender-only"
143
+ policy: "open" | "allowlist" | "sender-only",
144
+ client: GmailClient,
189
145
  ): Promise<ValidationResult> {
190
146
  if (policy === "open") {
191
147
  return { ok: true };
@@ -193,7 +149,11 @@ export async function validateThreadReply(
193
149
 
194
150
  let threadData: ThreadData;
195
151
  try {
196
- threadData = await fetchThreadData(threadId, accountEmail);
152
+ const thread = await client.getThread(threadId);
153
+ if (!thread) {
154
+ return { ok: false, reason: "Could not fetch thread data" };
155
+ }
156
+ threadData = extractThreadData(thread.messages, accountEmail);
197
157
  } catch (err) {
198
158
  console.error(`[gmail] Failed to fetch thread data for validation: ${err}`);
199
159
  return { ok: false, reason: `Could not fetch thread data: ${err}` };
@@ -204,15 +164,15 @@ export async function validateThreadReply(
204
164
  console.error(`[gmail] Could not determine original sender for thread ${threadId}`);
205
165
  return { ok: false, reason: "Could not determine thread sender" };
206
166
  }
207
-
167
+
208
168
  if (!isEmailAllowed(threadData.originalSender, allowOutboundTo)) {
209
- return {
210
- ok: false,
211
- blocked: [threadData.originalSender],
212
- reason: "Thread sender not in allowOutboundTo"
169
+ return {
170
+ ok: false,
171
+ blocked: [threadData.originalSender],
172
+ reason: "Thread sender not in allowOutboundTo"
213
173
  };
214
174
  }
215
-
175
+
216
176
  return { ok: true };
217
177
  }
218
178
 
package/src/outbound.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { spawn } from "node:child_process";
2
1
  import { marked } from "marked";
3
2
  import sanitizeHtml from "sanitize-html";
4
3
  import { type OutboundContext, type OpenClawConfig } from "openclaw/plugin-sdk";
@@ -7,74 +6,41 @@ import { isGmailThreadId } from "./normalize.js";
7
6
  import { fetchQuotedContext, type QuotedContent } from "./quoting.js";
8
7
  import { validateThreadReply, isEmailAllowed } from "./outbound-check.js";
9
8
  import type { GmailConfig } from "./config.js";
9
+ import type { GmailClient } from "./gmail-client.js";
10
10
 
11
11
  export interface GmailOutboundContext extends OutboundContext {
12
12
  subject?: string;
13
13
  threadId?: string;
14
14
  replyToId?: string;
15
- }
16
-
17
- async function spawnGog(args: string[], retries = 3): Promise<void> {
18
- for (let i = 0; i < retries; i++) {
19
- try {
20
- await new Promise<void>((resolve, reject) => {
21
- const proc = spawn("gog", args, { stdio: "pipe" });
22
- let err = "";
23
- let out = "";
24
- proc.stderr.on("data", (d) => err += d.toString());
25
- proc.stdout.on("data", (d) => out += d.toString());
26
- proc.on("error", (e) => reject(new Error(`gog failed to spawn: ${e.message}`)));
27
- proc.on("close", (code) => {
28
- if (code === 0) resolve();
29
- else reject(new Error(`gog failed (code ${code}): ${err || out}`));
30
- });
31
-
32
- // Add a timeout
33
- setTimeout(() => {
34
- proc.kill();
35
- reject(new Error("gog timed out after 30s"));
36
- }, 30000);
37
- });
38
- return;
39
- } catch (err) {
40
- if (i === retries - 1) throw err;
41
- await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
42
- }
43
- }
15
+ client: GmailClient;
44
16
  }
45
17
 
46
18
  export async function sendGmailText(ctx: GmailOutboundContext) {
47
- const { to, text, accountId, cfg, threadId, replyToId, subject: explicitSubject } = ctx;
19
+ const { to, text, accountId, cfg, threadId, replyToId, subject: explicitSubject, client } = ctx;
48
20
  const account = resolveGmailAccount(cfg, accountId);
49
21
  const gmailCfg = cfg.channels?.gmail as GmailConfig | undefined;
50
-
22
+
51
23
  // Validate we have a target - prioritize threadId if it's valid
52
24
  const effectiveThreadId = isGmailThreadId(String(threadId)) ? String(threadId) : undefined;
53
25
  const toValue = effectiveThreadId || to || "";
54
-
26
+
55
27
  if (!toValue) {
56
28
  throw new Error("Gmail send requires a valid 'to' address or thread ID");
57
29
  }
58
30
 
59
- const args = ["gmail", "send"];
60
-
61
- if (account.email) {
62
- args.push("--account", account.email);
63
- }
64
-
65
31
  // Determine if quoted replies are enabled (default: true)
66
32
  const accountCfg = gmailCfg?.accounts?.[accountId || "default"];
67
- const includeQuotedReplies = accountCfg?.includeQuotedReplies
68
- ?? gmailCfg?.defaults?.includeQuotedReplies
33
+ const includeQuotedReplies = accountCfg?.includeQuotedReplies
34
+ ?? gmailCfg?.defaults?.includeQuotedReplies
69
35
  ?? true;
70
36
 
71
37
  // Determine outbound restrictions
72
- const allowOutboundTo = accountCfg?.allowOutboundTo
73
- ?? gmailCfg?.defaults?.allowOutboundTo
74
- ?? account.allowFrom
38
+ const allowOutboundTo = accountCfg?.allowOutboundTo
39
+ ?? gmailCfg?.defaults?.allowOutboundTo
40
+ ?? account.allowFrom
75
41
  ?? [];
76
- const threadReplyPolicy = accountCfg?.threadReplyPolicy
77
- ?? gmailCfg?.defaults?.threadReplyPolicy
42
+ const threadReplyPolicy = accountCfg?.threadReplyPolicy
43
+ ?? gmailCfg?.defaults?.threadReplyPolicy
78
44
  ?? "open"; // Default: open for backwards compatibility
79
45
 
80
46
  const subject = explicitSubject || "(no subject)";
@@ -87,9 +53,10 @@ export async function sendGmailText(ctx: GmailOutboundContext) {
87
53
  toValue,
88
54
  account.email,
89
55
  allowOutboundTo,
90
- threadReplyPolicy
56
+ threadReplyPolicy,
57
+ client,
91
58
  );
92
-
59
+
93
60
  if (!validation.ok) {
94
61
  const blockedList = validation.blocked?.join(", ") || "unknown";
95
62
  throw new Error(
@@ -107,83 +74,70 @@ export async function sendGmailText(ctx: GmailOutboundContext) {
107
74
  }
108
75
  }
109
76
 
110
- if (!isThread) {
111
- args.push("--to", toValue);
112
- args.push("--subject", subject);
113
- } else {
114
- // Reply to thread
115
- if (replyToId) {
116
- args.push("--reply-to-message-id", String(replyToId));
117
- } else {
118
- args.push("--thread-id", toValue);
119
- }
120
-
121
- args.push("--subject", subject);
122
- args.push("--reply-all");
123
-
124
- // Fetch quoted thread context if enabled
125
- if (includeQuotedReplies && account.email) {
126
- try {
127
- quotedContent = await fetchQuotedContext(
128
- toValue,
129
- account.email,
130
- account.email
131
- );
132
- } catch (err) {
133
- // Non-fatal: proceed without quoted context
134
- console.error(`[gmail] Failed to fetch quoted context: ${err}`);
135
- }
136
- }
77
+ // Fetch quoted thread context if enabled
78
+ if (isThread && includeQuotedReplies && account.email) {
79
+ try {
80
+ quotedContent = await fetchQuotedContext(
81
+ toValue,
82
+ account.email,
83
+ client,
84
+ );
85
+ } catch (err) {
86
+ // Non-fatal: proceed without quoted context
87
+ console.error(`[gmail] Failed to fetch quoted context: ${err}`);
88
+ }
137
89
  }
138
90
 
139
91
  // Convert reply text to HTML (quotes are handled separately to avoid markdown mangling)
92
+ let fullHtml: string | undefined;
93
+ let plainBody = text;
94
+
140
95
  try {
141
- const rawHtml = await marked.parse(text);
142
- const replyHtml = sanitizeHtml(rawHtml, {
143
- allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
144
- allowedAttributes: {
145
- ...sanitizeHtml.defaults.allowedAttributes,
146
- '*': ['style', 'class']
147
- }
148
- });
149
-
150
- // Build Gmail-style blockquote HTML for the quoted content
151
- let fullHtml = replyHtml;
152
- let plainBody = text;
153
- if (quotedContent) {
154
- fullHtml += `<div class="gmail_quote">` +
155
- `<div dir="ltr" class="gmail_attr">${sanitizeHtml(quotedContent.header, { allowedTags: [], allowedAttributes: {} })}</div>` +
156
- `<blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">` +
157
- `${quotedContent.bodyHtml}` +
158
- `</blockquote></div>`;
159
- plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
96
+ const rawHtml = await marked.parse(text);
97
+ const replyHtml = sanitizeHtml(rawHtml, {
98
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
99
+ allowedAttributes: {
100
+ ...sanitizeHtml.defaults.allowedAttributes,
101
+ '*': ['style', 'class']
160
102
  }
103
+ });
161
104
 
162
- args.push("--body-html", fullHtml);
163
- args.push("--body", plainBody);
105
+ // Build Gmail-style blockquote HTML for the quoted content
106
+ fullHtml = replyHtml;
107
+ if (quotedContent) {
108
+ fullHtml += `<div class="gmail_quote">` +
109
+ `<div dir="ltr" class="gmail_attr">${sanitizeHtml(quotedContent.header, { allowedTags: [], allowedAttributes: {} })}</div>` +
110
+ `<blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">` +
111
+ `${quotedContent.bodyHtml}` +
112
+ `</blockquote></div>`;
113
+ plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
114
+ }
164
115
  } catch (err) {
165
- console.error("Markdown parsing or sanitization failed, sending plain text", err);
166
- let plainBody = text;
167
- if (quotedContent) {
168
- plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
169
- }
170
- args.push("--body", plainBody);
116
+ console.error("Markdown parsing or sanitization failed, sending plain text", err);
117
+ if (quotedContent) {
118
+ plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
119
+ }
171
120
  }
172
121
 
173
- await spawnGog(args);
122
+ await client.send({
123
+ account: account.email,
124
+ to: isThread ? undefined : toValue,
125
+ subject,
126
+ textBody: plainBody,
127
+ htmlBody: fullHtml,
128
+ threadId: isThread ? toValue : undefined,
129
+ replyToMessageId: replyToId ? String(replyToId) : undefined,
130
+ replyAll: isThread,
131
+ });
174
132
 
175
133
  // Archive if it was a thread (Reply = Archive), unless disabled by config
176
134
  const archiveOnReply = accountCfg?.archiveOnReply
177
135
  ?? gmailCfg?.defaults?.archiveOnReply
178
136
  ?? true;
179
137
  if (isThread && archiveOnReply) {
180
- const archiveArgs = ["gmail", "labels", "modify", toValue];
181
- if (account.email) archiveArgs.push("--account", account.email);
182
- archiveArgs.push("--remove", "INBOX");
183
-
184
138
  // Best effort archive
185
- spawnGog(archiveArgs).catch((err) => {
186
- console.error(`Failed to archive thread ${toValue}: ${err.message}`);
139
+ client.modifyLabels(toValue, { remove: ["INBOX"] }).catch((err) => {
140
+ console.error(`Failed to archive thread ${toValue}: ${err instanceof Error ? err.message : String(err)}`);
187
141
  });
188
142
  }
189
143
 
package/src/quoting.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { spawn } from "node:child_process";
2
1
  import sanitizeHtml from "sanitize-html";
2
+ import type { GmailClient } from "./gmail-client.js";
3
3
 
4
4
  export interface QuotedContent {
5
5
  header: string; // "On Mon, Feb 22, 2026 at 2:15 PM, John Doe wrote:"
@@ -26,8 +26,8 @@ export interface ThreadResponse {
26
26
  messages: ThreadMessage[];
27
27
  }
28
28
 
29
- // Raw gog output types
30
- interface GogThreadOutput {
29
+ // Raw gog output types — exported for use by GogGmailClient
30
+ export interface GogThreadOutput {
31
31
  downloaded: unknown;
32
32
  thread: {
33
33
  id: string;
@@ -36,7 +36,7 @@ interface GogThreadOutput {
36
36
  };
37
37
  }
38
38
 
39
- interface GogRawMessage {
39
+ export interface GogRawMessage {
40
40
  id: string;
41
41
  threadId: string;
42
42
  internalDate: string;
@@ -114,59 +114,18 @@ function parseGogMessage(raw: GogRawMessage): ThreadMessage {
114
114
  }
115
115
 
116
116
  /**
117
- * Fetch thread data from gog CLI
117
+ * Parse raw gog thread JSON into a ThreadResponse.
118
+ * Extracted from fetchThread() so GogGmailClient can reuse it.
118
119
  */
119
- async function fetchThread(threadId: string, account?: string): Promise<ThreadResponse | null> {
120
- const args = ["gmail", "thread", "get", threadId, "--full", "--json"];
121
- if (account) {
122
- args.push("--account", account);
123
- }
124
-
125
- return new Promise((resolve) => {
126
- const proc = spawn("gog", args, { stdio: "pipe" });
127
- let stdout = "";
128
- let stderr = "";
129
-
130
- proc.stdout.on("data", (d) => (stdout += d.toString()));
131
- proc.stderr.on("data", (d) => (stderr += d.toString()));
132
-
133
- proc.on("error", (e) => {
134
- console.error(`[gmail] Failed to spawn gog for thread fetch: ${e.message}`);
135
- resolve(null);
136
- });
137
-
138
- proc.on("close", (code) => {
139
- if (code !== 0) {
140
- console.error(`[gmail] gog thread get failed (code ${code}): ${stderr}`);
141
- resolve(null);
142
- return;
143
- }
144
- try {
145
- const parsed = JSON.parse(stdout) as GogThreadOutput;
146
- // gog wraps the thread in { downloaded, thread }
147
- const thread = parsed.thread;
148
- if (!thread || !thread.messages) {
149
- resolve(null);
150
- return;
151
- }
152
- resolve({
153
- id: thread.id,
154
- historyId: thread.historyId,
155
- messages: thread.messages.map(parseGogMessage),
156
- });
157
- } catch (e) {
158
- console.error(`[gmail] Failed to parse thread JSON: ${e}`);
159
- resolve(null);
160
- }
161
- });
162
-
163
- // Timeout after 15s
164
- setTimeout(() => {
165
- proc.kill();
166
- console.error(`[gmail] gog thread get timed out`);
167
- resolve(null);
168
- }, 15000);
169
- });
120
+ export function parseGogThreadOutput(data: unknown): ThreadResponse | null {
121
+ const parsed = data as GogThreadOutput;
122
+ const thread = parsed?.thread;
123
+ if (!thread || !thread.messages) return null;
124
+ return {
125
+ id: thread.id,
126
+ historyId: thread.historyId,
127
+ messages: thread.messages.map(parseGogMessage),
128
+ };
170
129
  }
171
130
 
172
131
  /**
@@ -267,9 +226,9 @@ export function buildQuotedThread(
267
226
  export async function fetchQuotedContext(
268
227
  threadId: string,
269
228
  accountEmail: string,
270
- accountArg?: string
229
+ client: GmailClient,
271
230
  ): Promise<QuotedContent | null> {
272
- const thread = await fetchThread(threadId, accountArg);
231
+ const thread = await client.getThread(threadId, { full: true });
273
232
  if (!thread || !thread.messages || thread.messages.length === 0) {
274
233
  return null;
275
234
  }