@mcinteerj/openclaw-gmail 1.6.1 → 1.7.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/README.md +29 -1
- package/package.json +1 -1
- package/src/accounts.ts +4 -0
- package/src/channel.ts +2 -0
- package/src/config.ts +2 -0
- package/src/monitor.ts +77 -1
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
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/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
|
},
|
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
|
-
|
|
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
|