@mcinteerj/openclaw-gmail 1.2.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 ADDED
@@ -0,0 +1,117 @@
1
+ # Gmail Channel (Plugin)
2
+
3
+ Connects Moltbot to Gmail via the `gog` CLI.
4
+
5
+ ## Installation
6
+
7
+ This is a plugin. To install from source:
8
+
9
+ ```bash
10
+ moltbot plugins install ./extensions/gmail
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - **Polling-based sync**: Robustly fetches new unread emails from Inbox.
16
+ - **Circuit Breaker**: Handles API failures and rate limiting gracefully.
17
+ - **Rich Text**: Markdown support for outbound emails.
18
+ - **Threading**: Native Gmail thread support with quoted reply context.
19
+ - **Archiving**: Automatically archives threads upon reply.
20
+ - **Email Body Sanitisation**: Automatically cleans incoming email bodies for LLM consumption.
21
+
22
+ ## Email Body Sanitisation
23
+
24
+ Incoming emails are automatically sanitised to produce clean, readable text — no configuration needed.
25
+
26
+ ### What It Does
27
+
28
+ - **HTML-to-text conversion**: Strips tags, removes `<style>` and `<script>` blocks, filters out tracking pixels, and decodes HTML entities.
29
+ - **Footer junk removal**: Strips common noise like unsubscribe links, "Sent from my iPhone", and confidentiality notices.
30
+ - **Whitespace cleanup**: Collapses excessive blank lines and trims leading/trailing whitespace.
31
+ - **Signature stripping**: Removes content below `-- ` signature separators by default.
32
+
33
+ ### Configurable Signature Stripping
34
+
35
+ Signature stripping is enabled by default. If you need to preserve content after `--` separators (e.g. for emails where dashes appear in the body), you can disable it programmatically:
36
+
37
+ ```ts
38
+ extractTextBody(html, plain, { stripSignature: false })
39
+ ```
40
+
41
+ No plugin configuration is required — sanitisation runs automatically on every inbound message.
42
+
43
+ ## Reply Behavior
44
+
45
+ - **Reply All**: When the bot replies to a thread, it uses "Reply All" to ensure all participants are included.
46
+ - **Quoted Replies**: By default, replies include the full thread history as quoted text (standard Gmail format: "On [date], [author] wrote:"). This can be disabled per-account or globally.
47
+ - **Allowlist Gatekeeping**: The bot only responds to emails from senders on the `allowFrom` list. However, if an allowed user includes others (CC) who are *not* on the allowlist, the bot will still "Reply All", including them in the conversation. This allows authorized users to bring others into the loop.
48
+ - **Outbound Restrictions** (optional): Use `allowOutboundTo` and `threadReplyPolicy` to control who the bot can send emails to. By default, no outbound restrictions are applied (backwards compatible).
49
+
50
+ ## Configuration
51
+
52
+ Add to `moltbot.json`:
53
+
54
+ ```json5
55
+ {
56
+ "channels": {
57
+ "gmail": {
58
+ "accounts": {
59
+ "main": {
60
+ "email": "user@gmail.com",
61
+ "allowFrom": ["*"],
62
+ "includeQuotedReplies": true, // default: true
63
+ // Optional: restrict who the bot can send emails TO
64
+ "allowOutboundTo": ["@mycompany.com", "partner@example.com"],
65
+ "threadReplyPolicy": "allowlist" // default: "open"
66
+ }
67
+ },
68
+ "defaults": {
69
+ "includeQuotedReplies": true // global default
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ```
75
+
76
+ ### Configuration Options
77
+
78
+ | Key | Type | Default | Description |
79
+ |-----|------|---------|-------------|
80
+ | `includeQuotedReplies` | boolean | `true` | Include thread history as quoted text in replies. Set to `false` for cleaner, shorter replies. |
81
+ | `allowOutboundTo` | string[] | (falls back to `allowFrom`) | Restrict who the bot can send emails to. Supports exact emails and domain wildcards (e.g., `@company.com`). |
82
+ | `threadReplyPolicy` | string | `"open"` | Controls thread reply behavior: `"open"` (no restrictions), `"allowlist"` (all recipients must be in `allowOutboundTo`), or `"sender-only"` (only original thread sender checked). |
83
+
84
+ ### Thread Reply Policies
85
+
86
+ - **`open`** (default): No outbound restrictions. The bot can reply to any thread it was BCCd into. Backwards compatible.
87
+ - **`allowlist`**: All thread participants (To, CC, From) must be in `allowOutboundTo`. Blocks replies if *any* recipient is not allowed.
88
+ - **`sender-only`**: Only checks if the original thread sender is in `allowOutboundTo`. Useful when you want to reply to threads started by allowed users, even if they CC'd external parties.
89
+
90
+ ## Development
91
+
92
+ Run tests:
93
+ ```bash
94
+ npm test
95
+ # or
96
+ ./node_modules/.bin/vitest run src/
97
+ ```
98
+
99
+ ## Publishing
100
+
101
+ This repo includes a GitHub Actions workflow for npm publishing.
102
+
103
+ ### Automatic (on release)
104
+ Create a GitHub release → automatically publishes to npm.
105
+
106
+ ### Manual (workflow dispatch)
107
+ Go to Actions → "Publish to npm" → Run workflow.
108
+
109
+ **Note:** Manual dispatch will:
110
+ 1. Bump the version (patch by default, or specify major/minor)
111
+ 2. Commit and push the version bump with a tag
112
+ 3. Publish to npm
113
+
114
+ ### Setup
115
+ Add `NPM_TOKEN` secret to the repository:
116
+ 1. Generate token at npmjs.com → Access Tokens → Classic Token (Automation)
117
+ 2. Add to repo: Settings → Secrets → Actions → New repository secret
package/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
2
+ import { gmailPlugin } from "./src/channel.js";
3
+
4
+ import { setGmailRuntime } from "./src/runtime.js";
5
+
6
+ const plugin = {
7
+ ...gmailPlugin,
8
+ register: (api: ClawdbotPluginApi) => {
9
+ setGmailRuntime(api.runtime);
10
+ api.registerChannel(gmailPlugin);
11
+ }
12
+ };
13
+
14
+ export default plugin;
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "openclaw-gmail",
3
+ "channels": [
4
+ "gmail"
5
+ ],
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {}
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@mcinteerj/openclaw-gmail",
3
+ "version": "1.2.0",
4
+ "description": "Gmail channel plugin for OpenClaw - uses gog CLI for secure Gmail access",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "author": "Jake McInteer <mcinteerj@gmail.com>",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/mcinteerj/openclaw-gmail"
12
+ },
13
+ "keywords": [
14
+ "openclaw",
15
+ "gmail",
16
+ "email",
17
+ "channel",
18
+ "plugin"
19
+ ],
20
+ "openclaw": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ],
24
+ "channel": {
25
+ "id": "gmail",
26
+ "label": "Gmail",
27
+ "selectionLabel": "Gmail (gog)",
28
+ "detailLabel": "Gmail",
29
+ "docsPath": "/channels/gmail",
30
+ "docsLabel": "gmail",
31
+ "blurb": "Uses gog for secure Gmail access.",
32
+ "systemImage": "envelope",
33
+ "order": 100,
34
+ "showConfigured": true
35
+ }
36
+ },
37
+ "files": [
38
+ "index.ts",
39
+ "src/**/*.ts",
40
+ "openclaw.plugin.json",
41
+ "README.md"
42
+ ],
43
+ "dependencies": {
44
+ "dompurify": "^3.2.3",
45
+ "jsdom": "^25.0.1",
46
+ "marked": "^15.0.6",
47
+ "proper-lockfile": "^4.1.2",
48
+ "sanitize-html": "^2.14.0",
49
+ "zod": "^4.3.5"
50
+ },
51
+ "peerDependencies": {
52
+ "openclaw": ">=2026.1.0"
53
+ }
54
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ type ChannelConfig,
3
+ type ResolvedChannelAccount,
4
+ DEFAULT_ACCOUNT_ID,
5
+ } from "openclaw/plugin-sdk";
6
+ import type { GmailConfig } from "./config.js";
7
+
8
+ export interface ResolvedGmailAccount extends ResolvedChannelAccount {
9
+ email: string;
10
+ historyId?: string;
11
+ delegate?: string;
12
+ pollIntervalMs?: number;
13
+ }
14
+
15
+ export function resolveGmailAccount(
16
+ cfg: ChannelConfig<GmailConfig>,
17
+ accountId?: string,
18
+ ): ResolvedGmailAccount {
19
+ const resolvedId = accountId || DEFAULT_ACCOUNT_ID;
20
+ const account = cfg.channels?.gmail?.accounts?.[resolvedId];
21
+
22
+ if (!account) {
23
+ // Graceful fallback for UI logic that queries 'default' on unconfigured channels
24
+ return {
25
+ accountId: resolvedId,
26
+ name: resolvedId,
27
+ enabled: false,
28
+ email: "",
29
+ historyId: undefined,
30
+ delegate: undefined,
31
+ allowFrom: [],
32
+ pollIntervalMs: undefined,
33
+ };
34
+ }
35
+
36
+ return {
37
+ accountId: resolvedId,
38
+ name: account.name || account.email,
39
+ enabled: account.enabled,
40
+ email: account.email,
41
+ historyId: account.historyId,
42
+ delegate: account.delegate,
43
+ allowFrom: account.allowFrom,
44
+ pollIntervalMs: account.pollIntervalMs,
45
+ };
46
+ }
47
+
48
+ export function listGmailAccountIds(cfg: ChannelConfig<GmailConfig>): string[] {
49
+ return Object.keys(cfg.channels?.gmail?.accounts || {});
50
+ }
51
+
52
+ export function resolveDefaultGmailAccountId(cfg: ChannelConfig<GmailConfig>): string {
53
+ const ids = listGmailAccountIds(cfg);
54
+ if (ids.length === 0) return DEFAULT_ACCOUNT_ID;
55
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
56
+ return ids[0]; // Fallback to first
57
+ }
@@ -0,0 +1,29 @@
1
+ import type { GogMessagePart } from "./inbound.js";
2
+
3
+ export interface GmailAttachment {
4
+ filename: string;
5
+ mimeType: string;
6
+ attachmentId: string;
7
+ size: number;
8
+ }
9
+
10
+ export function extractAttachments(part: GogMessagePart): GmailAttachment[] {
11
+ const attachments: GmailAttachment[] = [];
12
+
13
+ if (part.filename && part.body?.attachmentId) {
14
+ attachments.push({
15
+ filename: part.filename,
16
+ mimeType: part.mimeType,
17
+ attachmentId: part.body.attachmentId,
18
+ size: part.body.size,
19
+ });
20
+ }
21
+
22
+ if (part.parts) {
23
+ for (const subPart of part.parts) {
24
+ attachments.push(...extractAttachments(subPart));
25
+ }
26
+ }
27
+
28
+ return attachments;
29
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,360 @@
1
+ import {
2
+ buildChannelConfigSchema,
3
+ getChatChannelMeta,
4
+ type ChannelPlugin,
5
+ missingTargetError,
6
+ setAccountEnabledInConfigSection,
7
+ deleteAccountFromConfigSection,
8
+ type InboundMessage,
9
+ type OpenClawConfig,
10
+ type ChannelGatewayContext,
11
+ type MsgContext,
12
+ } from "openclaw/plugin-sdk";
13
+ import { GmailConfigSchema } from "./config.js";
14
+ import {
15
+ resolveGmailAccount,
16
+ resolveDefaultGmailAccountId,
17
+ listGmailAccountIds,
18
+ type ResolvedGmailAccount,
19
+ } from "./accounts.js";
20
+ import { setGmailRuntime, getGmailRuntime } from "./runtime.js";
21
+ import { sendGmailText, type GmailOutboundContext } from "./outbound.js";
22
+ import { gmailThreading } from "./threading.js";
23
+ import { normalizeGmailTarget, isGmailThreadId, isAllowed } from "./normalize.js";
24
+ import { parseInboundGmail, type GogPayload } from "./inbound.js";
25
+ import { monitorGmail, quarantineMessage } from "./monitor.js";
26
+ import { extractAttachments } from "./attachments.js";
27
+ import { Semaphore } from "./semaphore.js";
28
+ import crypto from "node:crypto";
29
+
30
+ const meta = {
31
+ id: "gmail",
32
+ label: "Gmail",
33
+ selectionLabel: "Gmail (gog)",
34
+ detailLabel: "Gmail",
35
+ docsPath: "/channels/gmail",
36
+ docsLabel: "gmail",
37
+ blurb: "Uses gog for secure Gmail access.",
38
+ systemImage: "envelope",
39
+ order: 100,
40
+ showConfigured: true,
41
+ };
42
+
43
+ // Map to store active account contexts
44
+ const activeAccounts = new Map<string, ChannelGatewayContext<ResolvedGmailAccount>>();
45
+
46
+ // Limit concurrent dispatches to avoid memory spikes
47
+ const dispatchSemaphore = new Semaphore(5);
48
+
49
+ /**
50
+ * Convert an InboundMessage to a finalized MsgContext for dispatch.
51
+ * Gmail threads are equivalent to Slack channels - each thread gets its own session.
52
+ */
53
+ function buildGmailMsgContext(
54
+ msg: InboundMessage,
55
+ account: ResolvedGmailAccount,
56
+ cfg: OpenClawConfig,
57
+ ): MsgContext {
58
+ const runtime = getGmailRuntime();
59
+ const to = `gmail:${account.email}`;
60
+ const threadLabel = `Gmail thread ${msg.threadId}`;
61
+
62
+ const ctx = runtime.channel.reply.finalizeInboundContext({
63
+ Body: msg.text,
64
+ RawBody: msg.text,
65
+ CommandBody: msg.text,
66
+ From: msg.sender.id,
67
+ To: to,
68
+ SessionKey: `gmail:${account.email}:${msg.threadId}`,
69
+ AccountId: msg.accountId,
70
+ ChatType: "direct",
71
+ ConversationLabel: threadLabel,
72
+ SenderName: msg.sender.name,
73
+ SenderId: msg.sender.id,
74
+ Provider: "gmail" as const,
75
+ Surface: "gmail" as const,
76
+ MessageSid: msg.channelMessageId,
77
+ ReplyToId: msg.channelMessageId,
78
+ ThreadLabel: threadLabel,
79
+ MessageThreadId: msg.threadId,
80
+ ThreadStarterBody: undefined,
81
+ Timestamp: msg.timestamp ? Math.round(msg.timestamp / 1_000) : undefined, // InboundMessage timestamp is ms, finalizeInboundContext expects seconds
82
+ MediaPath: msg.mediaPath,
83
+ MediaType: msg.mediaType,
84
+ MediaUrl: msg.mediaUrl,
85
+ CommandAuthorized: false,
86
+ OriginatingChannel: "gmail" as const,
87
+ OriginatingTo: to,
88
+ });
89
+
90
+ return ctx;
91
+ }
92
+
93
+ async function dispatchGmailMessage(
94
+ ctx: ChannelGatewayContext<ResolvedGmailAccount>,
95
+ msg: InboundMessage,
96
+ ) {
97
+ const { account, accountId, cfg, log } = ctx;
98
+ const runtime = getGmailRuntime();
99
+ const requestId = crypto.randomUUID().split("-")[0];
100
+
101
+ await dispatchSemaphore.run(async () => {
102
+ try {
103
+ log?.info(`[gmail][${requestId}] Dispatching message ${msg.channelMessageId} from ${msg.sender.id}`);
104
+
105
+ // Build the dispatch context
106
+ const ctxPayload = buildGmailMsgContext(msg, account, cfg);
107
+
108
+ // Build reply dispatcher options using gateway's reply capability
109
+ const deliver = async (payload: { text: string }) => {
110
+ const originalSubject = msg.raw?.subject ||
111
+ msg.raw?.headers?.subject ||
112
+ msg.raw?.payload?.headers?.find((h: any) => h.name.toLowerCase() === "subject")?.value;
113
+
114
+ const replySubject = originalSubject
115
+ ? (originalSubject.toLowerCase().startsWith("re:") ? originalSubject : `Re: ${originalSubject}`)
116
+ : "Re: ";
117
+
118
+ await sendGmailText({
119
+ to: msg.threadId || msg.sender.id,
120
+ text: payload.text,
121
+ accountId,
122
+ cfg,
123
+ threadId: msg.threadId,
124
+ replyToId: msg.channelMessageId,
125
+ subject: replySubject,
126
+ });
127
+ };
128
+
129
+ const humanDelay = runtime.channel.reply.resolveHumanDelayConfig(cfg, accountId);
130
+
131
+ // Dispatch to agent
132
+ await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
133
+ ctx: ctxPayload,
134
+ cfg,
135
+ dispatcherOptions: {
136
+ deliver,
137
+ humanDelay,
138
+ onError: (err: unknown, info: { kind: string }) => {
139
+ log?.error(`[gmail][${requestId}] ${info.kind} reply failed: ${String(err)}`);
140
+ },
141
+ },
142
+ });
143
+ log?.info(`[gmail][${requestId}] Dispatch complete for ${msg.channelMessageId}`);
144
+ } catch (e: unknown) {
145
+ log?.error(`[gmail][${requestId}] Dispatch failed: ${e instanceof Error ? e.message : String(e)}`);
146
+ }
147
+ });
148
+ }
149
+
150
+ import { gmailOnboardingAdapter } from "./onboarding.js";
151
+
152
+ export const gmailPlugin: ChannelPlugin<ResolvedGmailAccount> = {
153
+ id: "gmail",
154
+ onboarding: gmailOnboardingAdapter,
155
+ meta: {
156
+ ...meta,
157
+ showConfigured: true,
158
+ },
159
+ capabilities: {
160
+ chatTypes: ["direct", "group"],
161
+ media: true,
162
+ threads: true,
163
+ },
164
+ configSchema: {
165
+ schema: {
166
+ type: "object",
167
+ properties: {
168
+ enabled: { type: "boolean", default: true },
169
+ accounts: {
170
+ type: "object",
171
+ additionalProperties: {
172
+ type: "object",
173
+ properties: {
174
+ enabled: { type: "boolean", default: true },
175
+ email: { type: "string" },
176
+ name: { type: "string" },
177
+ allowFrom: { type: "array", items: { type: "string" } },
178
+ historyId: { type: "string" },
179
+ delegate: { type: "string" },
180
+ },
181
+ required: ["email"],
182
+ },
183
+ },
184
+ defaults: {
185
+ type: "object",
186
+ properties: {
187
+ allowFrom: { type: "array", items: { type: "string" } },
188
+ },
189
+ },
190
+ },
191
+ },
192
+ },
193
+ config: {
194
+ listAccountIds: (cfg) => listGmailAccountIds(cfg),
195
+ resolveAccount: (cfg, accountId) => resolveGmailAccount(cfg, accountId),
196
+ defaultAccountId: (cfg) => resolveDefaultGmailAccountId(cfg),
197
+ isEnabled: (account) => account.enabled,
198
+ describeAccount: (account) => ({
199
+ accountId: account.accountId,
200
+ name: account.name || account.email,
201
+ enabled: account.enabled,
202
+ configured: true,
203
+ linked: true,
204
+ allowFrom: account.allowFrom,
205
+ }),
206
+ resolveAllowFrom: ({ cfg, accountId }) =>
207
+ resolveGmailAccount(cfg, accountId ?? undefined).allowFrom ?? [],
208
+ formatAllowFrom: ({ allowFrom }) =>
209
+ allowFrom.map((e) => String(e).trim()).filter(Boolean),
210
+ setAccountEnabled: ({ cfg, accountId, enabled }) =>
211
+ setAccountEnabledInConfigSection({
212
+ cfg,
213
+ sectionKey: "gmail",
214
+ accountId,
215
+ enabled,
216
+ allowTopLevel: true,
217
+ }),
218
+ deleteAccount: ({ cfg, accountId }) =>
219
+ deleteAccountFromConfigSection({
220
+ cfg,
221
+ sectionKey: "gmail",
222
+ accountId,
223
+ }),
224
+ },
225
+ outbound: {
226
+ deliveryMode: "gateway",
227
+ textChunkLimit: 8000,
228
+ sendText: sendGmailText,
229
+ resolveTarget: ({ to, allowFrom }) => {
230
+ const trimmed = to?.trim() ?? "";
231
+ const normalized = normalizeGmailTarget(trimmed);
232
+
233
+ if (!normalized) {
234
+ return {
235
+ ok: false,
236
+ error: missingTargetError("Gmail", "email address or thread ID"),
237
+ };
238
+ }
239
+
240
+ // If it's a thread ID, we allow it implicitly (assuming we only have thread IDs
241
+ // for threads we were allowed to ingest).
242
+ if (isGmailThreadId(normalized)) {
243
+ return { ok: true, to: normalized };
244
+ }
245
+
246
+ // Security: check allowFrom for new email addresses
247
+ const allowed = (allowFrom || []).map((e) => String(e).trim());
248
+ if (allowed.includes("*")) {
249
+ return { ok: true, to: normalized };
250
+ }
251
+
252
+ if (allowed.length > 0) {
253
+ const isAllowed = allowed.some(entry => {
254
+ if (entry === normalized) return true;
255
+ if (entry.startsWith("@") && normalized.endsWith(entry)) return true;
256
+ return false;
257
+ });
258
+
259
+ if (!isAllowed) {
260
+ return { ok: false, error: new Error(`Recipient ${normalized} not in allowList`) };
261
+ }
262
+ }
263
+
264
+ return { ok: true, to: normalized };
265
+ },
266
+ },
267
+ threading: gmailThreading,
268
+ messaging: {
269
+ normalizeTarget: normalizeGmailTarget,
270
+ targetResolver: {
271
+ looksLikeId: (id) => normalizeGmailTarget(id) !== null,
272
+ hint: "email or threadId",
273
+ },
274
+ },
275
+ agentPrompt: {
276
+ messageToolHints: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => {
277
+ const account = resolveGmailAccount(cfg, accountId);
278
+ return [
279
+ "### Gmail Messaging",
280
+ "- To reply to this email, just write your response normally as text in your turn. This will Reply All to everyone on the thread.",
281
+ "- Your Markdown response is automatically converted to a rich HTML email using the `marked` library.",
282
+ "- Headings, tables, and code blocks are fully supported.",
283
+ `- Sending as: ${account.email || "the configured Gmail account"}.`,
284
+ "### Attachments",
285
+ "- **Location**: All attachments are stored in \`.attachments/{{threadId}}/\` relative to your workspace.",
286
+ "- **Auto-Download**: Files under 5MB are already there. The message text contains their paths.",
287
+ "- **Manual Download**: For larger files (listed with an ID), download them to that same folder:",
288
+ `- Command: \`mkdir -p .attachments/{{threadId}} && gog gmail attachment <messageId> <attachmentId> --account ${account.email} --out .attachments/{{threadId}}/<filename>\``,
289
+ ];
290
+ },
291
+ },
292
+ actions: {
293
+ listActions: () => ["send"],
294
+ supportsAction: ({ action }: { action: string }) => action === "send",
295
+ handleAction: async (ctx: any) => {
296
+ if (ctx.action !== "send") return { ok: false, error: new Error(`Unsupported action: ${ctx.action}`) };
297
+
298
+ const { params, accountId, cfg, toolContext } = ctx;
299
+ const to = (params.target || params.to) as string;
300
+ const text = params.message as string;
301
+
302
+ const isThread = isGmailThreadId(to);
303
+ let subject = params.subject as string | undefined;
304
+ let replyToId: string | undefined;
305
+
306
+ if (isThread && toolContext?.currentThreadTs) {
307
+ replyToId = toolContext.currentThreadTs;
308
+ }
309
+
310
+ await sendGmailText({
311
+ to,
312
+ text,
313
+ accountId,
314
+ cfg,
315
+ threadId: isThread ? to : undefined,
316
+ replyToId,
317
+ subject,
318
+ });
319
+
320
+ return { ok: true, content: [{ type: "text", text: "Message sent via Gmail." }] };
321
+ },
322
+ },
323
+ gateway: {
324
+ startAccount: async (ctx) => {
325
+ ctx.log?.info(`[gmail] Account ${ctx.account.accountId} started`);
326
+
327
+ if (ctx.account.email) {
328
+ activeAccounts.set(ctx.account.email.toLowerCase(), ctx);
329
+ }
330
+
331
+ ctx.setStatus({ accountId: ctx.accountId, running: true, connected: true });
332
+
333
+ // Create abort signal for stopping the monitor
334
+ const abortController = new AbortController();
335
+
336
+ // Start the Gmail polling monitor
337
+ monitorGmail({
338
+ account: ctx.account,
339
+ onMessage: async (msg) => {
340
+ await dispatchGmailMessage(ctx, msg);
341
+ },
342
+ signal: abortController.signal,
343
+ log: ctx.log,
344
+ setStatus: ctx.setStatus,
345
+ }).catch((err) => {
346
+ ctx.log?.error(`[gmail] Monitor error: ${String(err)}`);
347
+ });
348
+
349
+ return {
350
+ stop: async () => {
351
+ abortController.abort();
352
+ if (ctx.account.email) {
353
+ activeAccounts.delete(ctx.account.email.toLowerCase());
354
+ }
355
+ ctx.setStatus({ accountId: ctx.accountId, running: false, connected: false });
356
+ },
357
+ };
358
+ },
359
+ },
360
+ };
package/src/config.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { z } from "zod";
2
+
3
+ export const GmailAccountSchema = z.object({
4
+ accountId: z.string().optional(),
5
+ name: z.string().optional(),
6
+ enabled: z.boolean().default(true),
7
+ email: z.string(), // The Gmail email address
8
+ allowFrom: z.array(z.string()).default([]),
9
+ // Gmail specific settings
10
+ historyId: z.string().optional(), // For resuming history
11
+ delegate: z.string().optional(), // If using delegation
12
+ pollIntervalMs: z.number().optional(), // Polling interval in ms (default 60s)
13
+ // Reply behavior
14
+ includeQuotedReplies: z.boolean().optional(), // Include thread history in replies (default: true)
15
+ // Outbound restrictions (security)
16
+ allowOutboundTo: z.array(z.string()).optional(), // Who we can SEND to (if not set, falls back to allowFrom)
17
+ threadReplyPolicy: z.enum(["open", "allowlist", "sender-only"]).optional(), // Default: "open" for backwards compat
18
+ });
19
+
20
+ export const GmailConfigSchema = z.object({
21
+ enabled: z.boolean().default(true),
22
+ accounts: z.record(GmailAccountSchema).optional(),
23
+ defaults: z.object({
24
+ allowFrom: z.array(z.string()).optional(),
25
+ includeQuotedReplies: z.boolean().default(true), // Global default for quoted replies
26
+ allowOutboundTo: z.array(z.string()).optional(), // Global default for outbound allowlist
27
+ threadReplyPolicy: z.enum(["open", "allowlist", "sender-only"]).optional(), // Global default
28
+ }).optional(),
29
+ });
30
+
31
+ export type GmailConfig = z.infer<typeof GmailConfigSchema>;
32
+ export type GmailAccount = z.infer<typeof GmailAccountSchema>;