@mcinteerj/openclaw-gmail 1.2.8 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mcinteerj/openclaw-gmail",
3
- "version": "1.2.8",
3
+ "version": "1.3.1",
4
4
  "description": "Gmail channel plugin for OpenClaw - uses gog CLI for secure Gmail access",
5
5
  "type": "module",
6
6
  "main": "index.ts",
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,
@@ -65,7 +65,7 @@ function buildGmailMsgContext(
65
65
  CommandBody: msg.text,
66
66
  From: msg.sender.id,
67
67
  To: to,
68
- SessionKey: `gmail:${account.email}:${msg.threadId}`,
68
+ SessionKey: `agent:main:gmail:${account.email}:${msg.threadId}`,
69
69
  AccountId: msg.accountId,
70
70
  ChatType: "direct",
71
71
  ConversationLabel: threadLabel,
@@ -84,7 +84,7 @@ function buildGmailMsgContext(
84
84
  MediaUrl: msg.mediaUrl,
85
85
  CommandAuthorized: false,
86
86
  OriginatingChannel: "gmail" as const,
87
- OriginatingTo: to,
87
+ OriginatingTo: msg.threadId,
88
88
  });
89
89
 
90
90
  return ctx;
@@ -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
  },
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 and append quoted thread context if enabled
124
+ // Fetch quoted thread context if enabled
127
125
  if (includeQuotedReplies && account.email) {
128
126
  try {
129
- const quotedContext = await fetchQuotedContext(
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 body to HTML and sanitize
139
+ // Convert reply text to HTML (quotes are handled separately to avoid markdown mangling)
145
140
  try {
146
- const rawHtml = await marked.parse(body);
147
- const cleanHtml = sanitizeHtml(rawHtml, {
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
- args.push("--body-html", cleanHtml);
155
- args.push("--body", body);
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
- args.push("--body", body);
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
- if (isThread) {
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
- * Include message body as-is (flat, no ">" prefix to avoid staggered indentation)
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
- ): string {
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
- return `${header}\n\n${quoted}`;
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 the formatted quote block or empty string if unavailable.
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<string> {
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);