@mcinteerj/openclaw-gmail 1.2.7 → 1.3.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/package.json +1 -1
- package/src/channel.ts +27 -16
- package/src/config.ts +3 -0
- package/src/outbound.ts +32 -17
- package/src/quoting.ts +57 -9
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type ChannelGatewayContext,
|
|
11
11
|
type MsgContext,
|
|
12
12
|
} from "openclaw/plugin-sdk";
|
|
13
|
-
import { GmailConfigSchema } from "./config.js";
|
|
13
|
+
import { GmailConfigSchema, type GmailConfig } from "./config.js";
|
|
14
14
|
import {
|
|
15
15
|
resolveGmailAccount,
|
|
16
16
|
resolveDefaultGmailAccountId,
|
|
@@ -104,6 +104,7 @@ async function dispatchGmailMessage(
|
|
|
104
104
|
|
|
105
105
|
// Build the dispatch context
|
|
106
106
|
const ctxPayload = buildGmailMsgContext(msg, account, cfg);
|
|
107
|
+
const gmailCfg = cfg.channels?.gmail as GmailConfig | undefined;
|
|
107
108
|
|
|
108
109
|
// Build reply dispatcher options using gateway's reply capability
|
|
109
110
|
const deliver = async (payload: { text: string }) => {
|
|
@@ -139,6 +140,12 @@ async function dispatchGmailMessage(
|
|
|
139
140
|
log?.error(`[gmail][${requestId}] ${info.kind} reply failed: ${String(err)}`);
|
|
140
141
|
},
|
|
141
142
|
},
|
|
143
|
+
replyOptions: {
|
|
144
|
+
disableBlockStreaming:
|
|
145
|
+
typeof gmailCfg?.blockStreaming === "boolean"
|
|
146
|
+
? !gmailCfg.blockStreaming
|
|
147
|
+
: true, // Default: disabled for email
|
|
148
|
+
},
|
|
142
149
|
});
|
|
143
150
|
log?.info(`[gmail][${requestId}] Dispatch complete for ${msg.channelMessageId}`);
|
|
144
151
|
} catch (e: unknown) {
|
|
@@ -155,6 +162,7 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
155
162
|
meta: {
|
|
156
163
|
...meta,
|
|
157
164
|
id: "openclaw-gmail",
|
|
165
|
+
aliases: ["gmail"],
|
|
158
166
|
showConfigured: true,
|
|
159
167
|
},
|
|
160
168
|
capabilities: {
|
|
@@ -167,6 +175,7 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
167
175
|
type: "object",
|
|
168
176
|
properties: {
|
|
169
177
|
enabled: { type: "boolean", default: true },
|
|
178
|
+
blockStreaming: { type: "boolean", default: false },
|
|
170
179
|
accounts: {
|
|
171
180
|
type: "object",
|
|
172
181
|
additionalProperties: {
|
|
@@ -178,6 +187,7 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
178
187
|
allowFrom: { type: "array", items: { type: "string" } },
|
|
179
188
|
historyId: { type: "string" },
|
|
180
189
|
delegate: { type: "string" },
|
|
190
|
+
archiveOnReply: { type: "boolean", default: true },
|
|
181
191
|
},
|
|
182
192
|
required: ["email"],
|
|
183
193
|
},
|
|
@@ -186,6 +196,7 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
186
196
|
type: "object",
|
|
187
197
|
properties: {
|
|
188
198
|
allowFrom: { type: "array", items: { type: "string" } },
|
|
199
|
+
archiveOnReply: { type: "boolean", default: true },
|
|
189
200
|
},
|
|
190
201
|
},
|
|
191
202
|
},
|
|
@@ -335,31 +346,31 @@ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
|
|
|
335
346
|
|
|
336
347
|
ctx.setStatus({ accountId: ctx.accountId, running: true, connected: true });
|
|
337
348
|
|
|
338
|
-
//
|
|
339
|
-
|
|
349
|
+
// The channel manager treats startAccount's promise resolving as the channel
|
|
350
|
+
// "exiting", which triggers auto-restart. We must keep this promise pending
|
|
351
|
+
// until the channel manager signals stop via ctx.abortSignal.
|
|
352
|
+
const signal = ctx.abortSignal;
|
|
340
353
|
|
|
341
|
-
// Start the Gmail polling monitor
|
|
342
|
-
monitorGmail({
|
|
354
|
+
// Start the Gmail polling monitor (awaits until signal is aborted)
|
|
355
|
+
await monitorGmail({
|
|
343
356
|
account: ctx.account,
|
|
344
357
|
onMessage: async (msg) => {
|
|
345
358
|
await dispatchGmailMessage(ctx, msg);
|
|
346
359
|
},
|
|
347
|
-
signal
|
|
360
|
+
signal,
|
|
348
361
|
log: ctx.log,
|
|
349
362
|
setStatus: ctx.setStatus,
|
|
350
363
|
}).catch((err) => {
|
|
351
|
-
|
|
364
|
+
if (!signal.aborted) {
|
|
365
|
+
ctx.log?.error(`[gmail] Monitor error: ${String(err)}`);
|
|
366
|
+
}
|
|
352
367
|
});
|
|
353
368
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
}
|
|
360
|
-
ctx.setStatus({ accountId: ctx.accountId, running: false, connected: false });
|
|
361
|
-
},
|
|
362
|
-
};
|
|
369
|
+
// Cleanup after monitor exits
|
|
370
|
+
if (ctx.account.email) {
|
|
371
|
+
activeAccounts.delete(ctx.account.email.toLowerCase());
|
|
372
|
+
}
|
|
373
|
+
ctx.setStatus({ accountId: ctx.accountId, running: false, connected: false });
|
|
363
374
|
},
|
|
364
375
|
},
|
|
365
376
|
};
|
package/src/config.ts
CHANGED
|
@@ -15,16 +15,19 @@ export const GmailAccountSchema = z.object({
|
|
|
15
15
|
// Outbound restrictions (security)
|
|
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
|
+
archiveOnReply: z.boolean().optional(), // Archive thread after reply (default: true)
|
|
18
19
|
});
|
|
19
20
|
|
|
20
21
|
export const GmailConfigSchema = z.object({
|
|
21
22
|
enabled: z.boolean().default(true),
|
|
23
|
+
blockStreaming: z.boolean().optional(), // Enable block streaming for email (default: false — emails send as one message)
|
|
22
24
|
accounts: z.record(GmailAccountSchema).optional(),
|
|
23
25
|
defaults: z.object({
|
|
24
26
|
allowFrom: z.array(z.string()).optional(),
|
|
25
27
|
includeQuotedReplies: z.boolean().default(true), // Global default for quoted replies
|
|
26
28
|
allowOutboundTo: z.array(z.string()).optional(), // Global default for outbound allowlist
|
|
27
29
|
threadReplyPolicy: z.enum(["open", "allowlist", "sender-only"]).optional(), // Global default
|
|
30
|
+
archiveOnReply: z.boolean().optional(), // Global default for archive on reply (default: true)
|
|
28
31
|
}).optional(),
|
|
29
32
|
});
|
|
30
33
|
|
package/src/outbound.ts
CHANGED
|
@@ -4,7 +4,7 @@ import sanitizeHtml from "sanitize-html";
|
|
|
4
4
|
import { type OutboundContext, type OpenClawConfig } from "openclaw/plugin-sdk";
|
|
5
5
|
import { resolveGmailAccount } from "./accounts.js";
|
|
6
6
|
import { isGmailThreadId } from "./normalize.js";
|
|
7
|
-
import { fetchQuotedContext } from "./quoting.js";
|
|
7
|
+
import { fetchQuotedContext, type QuotedContent } from "./quoting.js";
|
|
8
8
|
import { validateThreadReply, isEmailAllowed } from "./outbound-check.js";
|
|
9
9
|
import type { GmailConfig } from "./config.js";
|
|
10
10
|
|
|
@@ -77,11 +77,9 @@ export async function sendGmailText(ctx: GmailOutboundContext) {
|
|
|
77
77
|
?? gmailCfg?.defaults?.threadReplyPolicy
|
|
78
78
|
?? "open"; // Default: open for backwards compatibility
|
|
79
79
|
|
|
80
|
-
// Build the body, potentially with quoted thread context
|
|
81
|
-
let body = text;
|
|
82
80
|
const subject = explicitSubject || "(no subject)";
|
|
83
|
-
|
|
84
81
|
const isThread = isGmailThreadId(toValue);
|
|
82
|
+
let quotedContent: QuotedContent | null = null;
|
|
85
83
|
|
|
86
84
|
// Validate outbound recipients
|
|
87
85
|
if (isThread && threadReplyPolicy !== "open" && account.email) {
|
|
@@ -123,17 +121,14 @@ export async function sendGmailText(ctx: GmailOutboundContext) {
|
|
|
123
121
|
args.push("--subject", subject);
|
|
124
122
|
args.push("--reply-all");
|
|
125
123
|
|
|
126
|
-
// Fetch
|
|
124
|
+
// Fetch quoted thread context if enabled
|
|
127
125
|
if (includeQuotedReplies && account.email) {
|
|
128
126
|
try {
|
|
129
|
-
|
|
127
|
+
quotedContent = await fetchQuotedContext(
|
|
130
128
|
toValue,
|
|
131
129
|
account.email,
|
|
132
130
|
account.email
|
|
133
131
|
);
|
|
134
|
-
if (quotedContext) {
|
|
135
|
-
body = `${text}\n\n${quotedContext}`;
|
|
136
|
-
}
|
|
137
132
|
} catch (err) {
|
|
138
133
|
// Non-fatal: proceed without quoted context
|
|
139
134
|
console.error(`[gmail] Failed to fetch quoted context: ${err}`);
|
|
@@ -141,27 +136,47 @@ export async function sendGmailText(ctx: GmailOutboundContext) {
|
|
|
141
136
|
}
|
|
142
137
|
}
|
|
143
138
|
|
|
144
|
-
// Convert
|
|
139
|
+
// Convert reply text to HTML (quotes are handled separately to avoid markdown mangling)
|
|
145
140
|
try {
|
|
146
|
-
const rawHtml = await marked.parse(
|
|
147
|
-
const
|
|
141
|
+
const rawHtml = await marked.parse(text);
|
|
142
|
+
const replyHtml = sanitizeHtml(rawHtml, {
|
|
148
143
|
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
|
149
144
|
allowedAttributes: {
|
|
150
145
|
...sanitizeHtml.defaults.allowedAttributes,
|
|
151
146
|
'*': ['style', 'class']
|
|
152
147
|
}
|
|
153
148
|
});
|
|
154
|
-
|
|
155
|
-
|
|
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}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
args.push("--body-html", fullHtml);
|
|
163
|
+
args.push("--body", plainBody);
|
|
156
164
|
} catch (err) {
|
|
157
165
|
console.error("Markdown parsing or sanitization failed, sending plain text", err);
|
|
158
|
-
|
|
166
|
+
let plainBody = text;
|
|
167
|
+
if (quotedContent) {
|
|
168
|
+
plainBody = `${text}\n\n${quotedContent.header}\n\n${quotedContent.bodyPlain}`;
|
|
169
|
+
}
|
|
170
|
+
args.push("--body", plainBody);
|
|
159
171
|
}
|
|
160
172
|
|
|
161
173
|
await spawnGog(args);
|
|
162
174
|
|
|
163
|
-
// Archive if it was a thread (Reply = Archive)
|
|
164
|
-
|
|
175
|
+
// Archive if it was a thread (Reply = Archive), unless disabled by config
|
|
176
|
+
const archiveOnReply = accountCfg?.archiveOnReply
|
|
177
|
+
?? gmailCfg?.defaults?.archiveOnReply
|
|
178
|
+
?? true;
|
|
179
|
+
if (isThread && archiveOnReply) {
|
|
165
180
|
const archiveArgs = ["gmail", "labels", "modify", toValue];
|
|
166
181
|
if (account.email) archiveArgs.push("--account", account.email);
|
|
167
182
|
archiveArgs.push("--remove", "INBOX");
|
package/src/quoting.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import sanitizeHtml from "sanitize-html";
|
|
3
|
+
|
|
4
|
+
export interface QuotedContent {
|
|
5
|
+
header: string; // "On Mon, Feb 22, 2026 at 2:15 PM, John Doe wrote:"
|
|
6
|
+
bodyHtml: string; // Sanitized HTML from original message
|
|
7
|
+
bodyPlain: string; // Plain text from original message (with > prefix)
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
export interface ThreadMessage {
|
|
4
11
|
id: string;
|
|
@@ -9,6 +16,7 @@ export interface ThreadMessage {
|
|
|
9
16
|
cc?: string;
|
|
10
17
|
subject: string;
|
|
11
18
|
body: string;
|
|
19
|
+
bodyHtml: string;
|
|
12
20
|
labels: string[];
|
|
13
21
|
}
|
|
14
22
|
|
|
@@ -67,6 +75,22 @@ function extractBody(msg: GogRawMessage): string {
|
|
|
67
75
|
return "";
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Extract HTML body from gog message
|
|
80
|
+
*/
|
|
81
|
+
function extractHtmlBody(msg: GogRawMessage): string {
|
|
82
|
+
if (msg.payload.parts) {
|
|
83
|
+
const htmlPart = msg.payload.parts.find((p) => p.mimeType === "text/html");
|
|
84
|
+
if (htmlPart?.body?.data) {
|
|
85
|
+
return Buffer.from(htmlPart.body.data, "base64").toString("utf-8");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (msg.payload.body?.data) {
|
|
89
|
+
return Buffer.from(msg.payload.body.data, "base64").toString("utf-8");
|
|
90
|
+
}
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
70
94
|
/**
|
|
71
95
|
* Convert gog raw message to our ThreadMessage format
|
|
72
96
|
*/
|
|
@@ -75,6 +99,7 @@ function parseGogMessage(raw: GogRawMessage): ThreadMessage {
|
|
|
75
99
|
const date = getHeader(raw, "Date") || new Date(parseInt(raw.internalDate)).toISOString();
|
|
76
100
|
const subject = getHeader(raw, "Subject") || "";
|
|
77
101
|
const body = extractBody(raw);
|
|
102
|
+
const bodyHtml = extractHtmlBody(raw);
|
|
78
103
|
|
|
79
104
|
return {
|
|
80
105
|
id: raw.id,
|
|
@@ -83,6 +108,7 @@ function parseGogMessage(raw: GogRawMessage): ThreadMessage {
|
|
|
83
108
|
from,
|
|
84
109
|
subject,
|
|
85
110
|
body,
|
|
111
|
+
bodyHtml,
|
|
86
112
|
labels: raw.labelIds || [],
|
|
87
113
|
};
|
|
88
114
|
}
|
|
@@ -176,21 +202,37 @@ function extractSenderDisplay(from: string): string {
|
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
/**
|
|
179
|
-
*
|
|
205
|
+
* Prefix each line with "> " for plain text quoting
|
|
180
206
|
*/
|
|
181
207
|
function quoteBody(body: string): string {
|
|
182
|
-
return body;
|
|
208
|
+
return body.split("\n").map((line) => `> ${line}`).join("\n");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Sanitize HTML for safe embedding in a blockquote.
|
|
213
|
+
* Preserves formatting (bold, links, lists) while removing dangerous content.
|
|
214
|
+
*/
|
|
215
|
+
function sanitizeQuoteHtml(html: string): string {
|
|
216
|
+
return sanitizeHtml(html, {
|
|
217
|
+
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "span", "div", "br", "hr"]),
|
|
218
|
+
allowedAttributes: {
|
|
219
|
+
...sanitizeHtml.defaults.allowedAttributes,
|
|
220
|
+
"*": ["style", "class", "dir"],
|
|
221
|
+
},
|
|
222
|
+
});
|
|
183
223
|
}
|
|
184
224
|
|
|
185
225
|
/**
|
|
186
226
|
* Build quoted context from the most recent non-self message.
|
|
187
227
|
* Gmail standard: only quote the last message (which itself contains older quotes).
|
|
188
228
|
* This avoids nested/staggered quoting.
|
|
229
|
+
*
|
|
230
|
+
* Returns structured QuotedContent for separate HTML and plain text rendering.
|
|
189
231
|
*/
|
|
190
232
|
export function buildQuotedThread(
|
|
191
233
|
messages: ThreadMessage[],
|
|
192
234
|
accountEmail: string
|
|
193
|
-
):
|
|
235
|
+
): QuotedContent | null {
|
|
194
236
|
// Filter out messages from the account itself
|
|
195
237
|
const otherMessages = messages.filter((msg) => {
|
|
196
238
|
const emailMatch = msg.from.match(/<(.*)>/);
|
|
@@ -199,7 +241,7 @@ export function buildQuotedThread(
|
|
|
199
241
|
});
|
|
200
242
|
|
|
201
243
|
if (otherMessages.length === 0) {
|
|
202
|
-
return
|
|
244
|
+
return null;
|
|
203
245
|
}
|
|
204
246
|
|
|
205
247
|
// Only quote the most recent message from others (last in array = newest)
|
|
@@ -207,23 +249,29 @@ export function buildQuotedThread(
|
|
|
207
249
|
const sender = extractSenderDisplay(lastMsg.from);
|
|
208
250
|
const date = formatQuoteDate(lastMsg.date);
|
|
209
251
|
const header = `On ${date}, ${sender} wrote:`;
|
|
210
|
-
const quoted = quoteBody(lastMsg.body.trim());
|
|
211
252
|
|
|
212
|
-
|
|
253
|
+
// HTML: sanitize original HTML body, fall back to plain text
|
|
254
|
+
const rawHtml = lastMsg.bodyHtml || lastMsg.body;
|
|
255
|
+
const bodyHtml = sanitizeQuoteHtml(rawHtml);
|
|
256
|
+
|
|
257
|
+
// Plain text: prefix each line with >
|
|
258
|
+
const bodyPlain = quoteBody(lastMsg.body.trim());
|
|
259
|
+
|
|
260
|
+
return { header, bodyHtml, bodyPlain };
|
|
213
261
|
}
|
|
214
262
|
|
|
215
263
|
/**
|
|
216
264
|
* Fetch and format quoted context for a thread reply.
|
|
217
|
-
* Returns
|
|
265
|
+
* Returns structured QuotedContent or null if unavailable.
|
|
218
266
|
*/
|
|
219
267
|
export async function fetchQuotedContext(
|
|
220
268
|
threadId: string,
|
|
221
269
|
accountEmail: string,
|
|
222
270
|
accountArg?: string
|
|
223
|
-
): Promise<
|
|
271
|
+
): Promise<QuotedContent | null> {
|
|
224
272
|
const thread = await fetchThread(threadId, accountArg);
|
|
225
273
|
if (!thread || !thread.messages || thread.messages.length === 0) {
|
|
226
|
-
return
|
|
274
|
+
return null;
|
|
227
275
|
}
|
|
228
276
|
|
|
229
277
|
return buildQuotedThread(thread.messages, accountEmail);
|