@mcinteerj/openclaw-gmail 1.6.1 → 1.7.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 CHANGED
@@ -106,12 +106,14 @@ Your gog installation is not affected and other accounts can continue using it.
106
106
  "allowFrom": ["*"],
107
107
  "pollIntervalMs": 60000,
108
108
  "includeQuotedReplies": true, // default: true
109
+ "includeThreadContext": false, // default: false
109
110
  "allowOutboundTo": ["@company.com"], // optional
110
111
  "threadReplyPolicy": "allowlist" // default: "open"
111
112
  }
112
113
  },
113
114
  "defaults": {
114
- "includeQuotedReplies": true
115
+ "includeQuotedReplies": true,
116
+ "includeThreadContext": false
115
117
  }
116
118
  }
117
119
  }
@@ -125,6 +127,7 @@ Your gog installation is not affected and other accounts can continue using it.
125
127
  | `allowFrom` | string[] | `[]` | Sender allowlist. `["*"]` allows all. |
126
128
  | `pollIntervalMs` | number | `60000` | Polling interval in milliseconds |
127
129
  | `includeQuotedReplies` | boolean | `true` | Include thread history as quoted text in replies |
130
+ | `includeThreadContext` | boolean | `false` | When an allowed sender replies in a thread containing messages from non-allowed senders, include those earlier messages as context. See [Thread Context](#thread-context). |
128
131
  | `allowOutboundTo` | string[] | (falls back to `allowFrom`) | Restrict who the bot can send to. Supports domain wildcards (`@company.com`). |
129
132
  | `threadReplyPolicy` | `"open"` \| `"allowlist"` \| `"sender-only"` | `"open"` | Controls reply restrictions |
130
133
 
@@ -134,6 +137,31 @@ Your gog installation is not affected and other accounts can continue using it.
134
137
  - **`allowlist`**: All thread participants must be in `allowOutboundTo`.
135
138
  - **`sender-only`**: Only checks if the original thread sender is allowed.
136
139
 
140
+ ### Thread Context
141
+
142
+ When `includeThreadContext` is enabled, the plugin enriches inbound messages with prior thread history that the agent wouldn't otherwise see.
143
+
144
+ **The problem:** If someone not on the allow list emails the agent, their message is quarantined (never seen). If an allowed sender then replies to that thread asking the agent to review it, the agent only sees the allowed sender's new message — the quoted content from the non-allowed sender is stripped during sanitization.
145
+
146
+ **The solution:** With `includeThreadContext: true`, when an allowed sender's message arrives in a thread that contains earlier messages from non-allowed senders, those earlier messages are included as labelled context above the new message:
147
+
148
+ ```
149
+ ---
150
+ **Thread context** (1 earlier message from senders not on your allow list):
151
+
152
+ **From:** Hamish Smith <hamish@example.com>
153
+ **Date:** Mon, 24 Feb 2026 10:30:00 +1300
154
+
155
+ Hey Keith, can you book transfers for our Fiji trip?
156
+ ---
157
+
158
+ [Thread Context: ID=abc123, Subject="Book Your Fast Fiji Transfers"]
159
+
160
+ Keith, can you please review Hamish's request below and action it?
161
+ ```
162
+
163
+ This is **disabled by default** to preserve the existing allow list behavior where non-allowed senders' content is never shown. Enable it per-account or in `defaults` when you want allowed senders to be able to surface thread context from outside the allow list.
164
+
137
165
  ## Development
138
166
 
139
167
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcinteerj/openclaw-gmail",
3
- "version": "1.6.1",
3
+ "version": "1.7.1",
4
4
  "description": "Gmail channel plugin for OpenClaw - direct API or gog CLI",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/src/accounts.ts CHANGED
@@ -10,6 +10,7 @@ export interface ResolvedGmailAccount extends ResolvedChannelAccount {
10
10
  historyId?: string;
11
11
  delegate?: string;
12
12
  pollIntervalMs?: number;
13
+ includeThreadContext?: boolean;
13
14
  backend?: "gog" | "api";
14
15
  oauth?: {
15
16
  clientId: string;
@@ -63,6 +64,8 @@ export function resolveGmailAccount(
63
64
  };
64
65
  }
65
66
 
67
+ const defaults = cfg.channels?.['openclaw-gmail']?.defaults;
68
+
66
69
  return {
67
70
  accountId: resolvedId,
68
71
  name: account.name || account.email,
@@ -72,6 +75,7 @@ export function resolveGmailAccount(
72
75
  delegate: account.delegate,
73
76
  allowFrom: account.allowFrom,
74
77
  pollIntervalMs: account.pollIntervalMs,
78
+ includeThreadContext: account.includeThreadContext ?? (defaults as any)?.includeThreadContext ?? false,
75
79
  backend: account.backend,
76
80
  oauth: account.oauth,
77
81
  };
package/src/api-client.ts CHANGED
@@ -2,7 +2,7 @@ import { gmail as gmailApi, type gmail_v1 } from "@googleapis/gmail";
2
2
  import type { OAuth2Client } from "google-auth-library";
3
3
  import fs from "node:fs/promises";
4
4
  import type { GmailClient } from "./gmail-client.js";
5
- import type { ThreadResponse, GogRawMessage } from "./quoting.js";
5
+ import type { ThreadResponse, GogRawMessage, GogRawMessagePart } from "./quoting.js";
6
6
  import type { GogSearchMessage } from "./inbound.js";
7
7
  import { buildMimeMessage } from "./mime.js";
8
8
  import { parseEmailAddresses } from "./outbound-check.js";
@@ -346,6 +346,23 @@ function mapApiMessage(msg: gmail_v1.Schema$Message): GogRawMessage {
346
346
  };
347
347
  }
348
348
 
349
+ function mapPart(p: gmail_v1.Schema$MessagePart): GogRawMessagePart {
350
+ return {
351
+ partId: p.partId ?? undefined,
352
+ mimeType: p.mimeType!,
353
+ filename: p.filename ?? undefined,
354
+ headers: p.headers?.map((h) => ({ name: h.name!, value: h.value! })),
355
+ body: p.body
356
+ ? {
357
+ size: p.body.size ?? undefined,
358
+ data: p.body.data ?? undefined,
359
+ attachmentId: p.body.attachmentId ?? undefined,
360
+ }
361
+ : undefined,
362
+ parts: p.parts?.map(mapPart),
363
+ };
364
+ }
365
+
349
366
  function mapPayload(
350
367
  p: gmail_v1.Schema$MessagePart,
351
368
  ): GogRawMessage["payload"] {
@@ -354,11 +371,14 @@ function mapPayload(
354
371
  name: h.name!,
355
372
  value: h.value!,
356
373
  })),
357
- parts: p.parts?.map((part) => ({
358
- body: part.body?.data ? { data: part.body.data } : undefined,
359
- mimeType: part.mimeType!,
360
- })),
361
- body: p.body?.data ? { data: p.body.data } : undefined,
374
+ parts: p.parts?.map(mapPart),
375
+ body: p.body
376
+ ? {
377
+ size: p.body.size ?? undefined,
378
+ data: p.body.data ?? undefined,
379
+ attachmentId: p.body.attachmentId ?? undefined,
380
+ }
381
+ : undefined,
362
382
  };
363
383
  }
364
384
 
package/src/channel.ts CHANGED
@@ -190,6 +190,7 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
190
190
  historyId: { type: "string" },
191
191
  delegate: { type: "string" },
192
192
  archiveOnReply: { type: "boolean", default: true },
193
+ includeThreadContext: { type: "boolean", default: false },
193
194
  backend: { type: "string", enum: ["gog", "api"] },
194
195
  oauth: {
195
196
  type: "object",
@@ -209,6 +210,7 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
209
210
  properties: {
210
211
  allowFrom: { type: "array", items: { type: "string" } },
211
212
  archiveOnReply: { type: "boolean", default: true },
213
+ includeThreadContext: { type: "boolean", default: false },
212
214
  },
213
215
  },
214
216
  },
@@ -317,6 +319,7 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
317
319
  "- Work silently; send ONE reply when complete. For long-running tasks, notify before starting or after completing.",
318
320
  "",
319
321
  "### Gmail Messaging",
322
+ "- **Reactions**: Gmail emoji reactions (e.g. 👍, ❤️) appear as emails but generally don't need a reply. Unless the context clearly warrants a response, you can skip replying to reaction-only messages.",
320
323
  "- To reply to this email, just write your response normally as text in your turn. This will Reply All to everyone on the thread.",
321
324
  "- Your Markdown response is automatically converted to a rich HTML email using the `marked` library.",
322
325
  "- Headings, tables, and code blocks are fully supported.",
package/src/config.ts CHANGED
@@ -16,6 +16,7 @@ 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
+ includeThreadContext: z.boolean().optional(), // Include prior thread messages from non-allowed senders when an allowed sender replies (default: false)
19
20
  backend: z.enum(["gog", "api"]).optional(), // Gmail backend: gog CLI (default) or googleapis
20
21
  oauth: z.object({
21
22
  clientId: z.string(),
@@ -34,6 +35,7 @@ export const GmailConfigSchema = z.object({
34
35
  allowOutboundTo: z.array(z.string()).optional(), // Global default for outbound allowlist
35
36
  threadReplyPolicy: z.enum(["open", "allowlist", "sender-only"]).optional(), // Global default
36
37
  archiveOnReply: z.boolean().optional(), // Global default for archive on reply (default: true)
38
+ includeThreadContext: z.boolean().optional(), // Global default for thread context inclusion (default: false)
37
39
  }).optional(),
38
40
  });
39
41
 
package/src/monitor.ts CHANGED
@@ -9,6 +9,7 @@ import { extractAttachments } from "./attachments.js";
9
9
  import { isAllowed } from "./normalize.js";
10
10
  import type { GmailClient } from "./gmail-client.js";
11
11
  import { GogGmailClient } from "./gog-client.js";
12
+ import { extractTextBody } from "./strip-quotes.js";
12
13
 
13
14
  // Polling interval: Default 60s, override via env for testing
14
15
  const DEFAULT_POLL_INTERVAL = 60_000;
@@ -54,6 +55,78 @@ async function markAsRead(id: string, threadId: string | undefined, log: Channel
54
55
  }
55
56
  }
56
57
 
58
+ /**
59
+ * Enrich an inbound message with prior thread messages that Keith hasn't seen.
60
+ *
61
+ * When `includeThreadContext` is enabled and an allowed sender replies in a thread
62
+ * that contains earlier messages from non-allowed senders (which were quarantined),
63
+ * this fetches the full thread and prepends those unseen messages as context.
64
+ *
65
+ * This solves the case where e.g. Hamish (not allowed) emails, gets quarantined,
66
+ * then Laura (allowed) replies asking Keith to review — Keith now sees Hamish's
67
+ * message as thread context above Laura's new message.
68
+ */
69
+ async function enrichWithThreadContext(
70
+ msg: InboundMessage,
71
+ account: ResolvedGmailAccount,
72
+ log: ChannelLogSink,
73
+ client: GmailClient,
74
+ ): Promise<InboundMessage> {
75
+ if (!account.includeThreadContext) return msg;
76
+ if (!msg.threadId) return msg;
77
+
78
+ try {
79
+ const thread = await client.getThread(msg.threadId, { full: true });
80
+ if (!thread?.messages || thread.messages.length <= 1) return msg;
81
+
82
+ const allowList = account.allowFrom || [];
83
+
84
+ // Find messages in the thread that Keith hasn't seen (from non-allowed senders)
85
+ // Exclude the current message itself
86
+ const unseenMessages = thread.messages.filter((threadMsg) => {
87
+ if (threadMsg.id === msg.channelMessageId) return false;
88
+
89
+ // Extract sender email
90
+ const fromMatch = threadMsg.from.match(/<(.*)>/);
91
+ const senderEmail = fromMatch ? fromMatch[1] : threadMsg.from;
92
+
93
+ // Skip messages from the account itself (Keith's own replies)
94
+ if (senderEmail.toLowerCase() === account.email.toLowerCase()) return false;
95
+
96
+ // Only include messages from senders NOT on the allow list
97
+ return !isAllowed(senderEmail, allowList);
98
+ });
99
+
100
+ if (unseenMessages.length === 0) return msg;
101
+
102
+ // Build context block from unseen messages (oldest first)
103
+ const contextLines = unseenMessages.map((threadMsg) => {
104
+ // Strip quotes from thread message body to avoid nested repetition
105
+ const cleanBody = extractTextBody(threadMsg.bodyHtml, threadMsg.body, { stripSignature: true });
106
+ const body = cleanBody || threadMsg.body || "(no content)";
107
+ return `**From:** ${threadMsg.from}\n**Date:** ${threadMsg.date}\n\n${body}`;
108
+ });
109
+
110
+ const contextBlock = [
111
+ "---",
112
+ `**Thread context** (${unseenMessages.length} earlier message${unseenMessages.length > 1 ? "s" : ""} from senders not on your allow list):`,
113
+ "",
114
+ ...contextLines.map((c, i) => i > 0 ? `---\n${c}` : c),
115
+ "---",
116
+ "",
117
+ ].join("\n");
118
+
119
+ // Prepend thread context before the current message text
120
+ return {
121
+ ...msg,
122
+ text: contextBlock + msg.text,
123
+ };
124
+ } catch (err) {
125
+ log.error(`Failed to enrich thread context for ${msg.threadId}: ${String(err)}`);
126
+ return msg; // Graceful fallback — dispatch without context
127
+ }
128
+ }
129
+
57
130
  /**
58
131
  * Prune old Gmail sessions and their associated attachments.
59
132
  */
@@ -251,7 +324,7 @@ async function performFullSync(
251
324
 
252
325
  // To get attachments, we need the full message details (search --include-body only gives text)
253
326
  const fullMsg = await fetchMessageDetails(msg.channelMessageId, account, log, client, true);
254
- const msgToDispatch = fullMsg || msg;
327
+ let msgToDispatch = fullMsg || msg;
255
328
 
256
329
  try {
257
330
  // Auto-download small attachments
@@ -263,6 +336,9 @@ async function performFullSync(
263
336
  }
264
337
  }
265
338
 
339
+ // Enrich with thread context from non-allowed senders if enabled
340
+ msgToDispatch = await enrichWithThreadContext(msgToDispatch, account, log, client);
341
+
266
342
  await onMessage(msgToDispatch);
267
343
 
268
344
  // CRITICAL: Only mark as read after successful dispatch
package/src/quoting.ts CHANGED
@@ -36,6 +36,15 @@ export interface GogThreadOutput {
36
36
  };
37
37
  }
38
38
 
39
+ export interface GogRawMessagePart {
40
+ partId?: string;
41
+ mimeType: string;
42
+ filename?: string;
43
+ headers?: { name: string; value: string }[];
44
+ body?: { size?: number; data?: string; attachmentId?: string };
45
+ parts?: GogRawMessagePart[];
46
+ }
47
+
39
48
  export interface GogRawMessage {
40
49
  id: string;
41
50
  threadId: string;
@@ -43,8 +52,8 @@ export interface GogRawMessage {
43
52
  labelIds: string[];
44
53
  payload: {
45
54
  headers: { name: string; value: string }[];
46
- parts?: { body?: { data?: string }; mimeType: string }[];
47
- body?: { data?: string };
55
+ parts?: GogRawMessagePart[];
56
+ body?: { size?: number; data?: string; attachmentId?: string };
48
57
  };
49
58
  }
50
59