From 00bf3a53804c41691f71d01dff4027e0755f7560 Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 21 Apr 2026 22:49:48 -0300 Subject: [PATCH 1/3] feat(google-gmail): add TRIGGER binding for email received events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the TRIGGER_LIST/TRIGGER_CONFIGURE tools and webhook ingestion for gmail.message.received, following the same patterns used by the GitHub and Slack MCPs. - Add trigger-store using createTriggers from @decocms/runtime/triggers - Add email-connection-map for routing Pub/Sub notifications to connections - Add webhook handler for POST /webhooks/gmail (Google Pub/Sub push) - Wire onChange to fetch user profile, map email→connectionId, and call users.watch() for push notification registration - Bump @decocms/runtime to 1.5.0, add @decocms/bindings ^1.4.0 Requires GMAIL_PUBSUB_TOPIC env var for push notification setup. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 15 ++- google-gmail/package.json | 3 +- google-gmail/server/constants.ts | 10 ++ .../server/lib/email-connection-map.ts | 23 +++++ google-gmail/server/lib/trigger-store.ts | 22 +++++ google-gmail/server/main.ts | 94 ++++++++++++++++++- google-gmail/server/tools/index.ts | 4 + google-gmail/server/webhook.ts | 72 ++++++++++++++ 8 files changed, 234 insertions(+), 9 deletions(-) create mode 100644 google-gmail/server/lib/email-connection-map.ts create mode 100644 google-gmail/server/lib/trigger-store.ts create mode 100644 google-gmail/server/webhook.ts diff --git a/bun.lock b/bun.lock index 06e65945..9ae8976c 100644 --- a/bun.lock +++ b/bun.lock @@ -378,7 +378,8 @@ "name": "google-gmail", "version": "1.0.0", "dependencies": { - "@decocms/runtime": "1.2.5", + "@decocms/bindings": "^1.4.0", + "@decocms/runtime": "1.5.0", "zod": "^4.0.0", }, "devDependencies": { @@ -4020,7 +4021,9 @@ "google-gemini/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "google-gmail/@decocms/runtime": ["@decocms/runtime@1.2.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.1.1", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-0s02lfj/O7nTAc7FTmFsA+lZpUDnapjQHnRYrQXItLKrbJvjSnfoq5V8HA1Npv5HelBvsVk7QQHaW8pSN/l37w=="], + "google-gmail/@decocms/bindings": ["@decocms/bindings@1.4.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.27.1", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-olUAzaV/lAaBLW5Z+sedJtms3vbUOL9WYXOU2Wkh311Kk1LBOuQmbJrVNVZH4yj8j2UVWxFVPcjkT9gxAC0zdw=="], + + "google-gmail/@decocms/runtime": ["@decocms/runtime@1.5.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.27.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" }, "peerDependencies": { "ai": ">=6.0.0" } }, "sha512-TVwuutWjghkDtt/6ylxXyUEgbjDA8xg9tjnMZ3kq3DO1RfhTuaAW0YKuf6AJPN+aYno89VSQ8HBVJ7phwIelpw=="], "google-gmail/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -4514,9 +4517,9 @@ "google-gemini/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], - "google-gmail/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.3", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-+4/VOOVERB8UixGKmN0VkLazxeMAahbG0A9xOYTPL+MJIAM30htrLG2aHI2Dm5ASgccAD4bW5RuLqv2PDFZZPA=="], + "google-gmail/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], - "google-gmail/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], + "google-gmail/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], "google-meet/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.3", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-+4/VOOVERB8UixGKmN0VkLazxeMAahbG0A9xOYTPL+MJIAM30htrLG2aHI2Dm5ASgccAD4bW5RuLqv2PDFZZPA=="], @@ -4986,7 +4989,9 @@ "google-gemini/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], - "google-gmail/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + "google-gmail/@decocms/bindings/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + + "google-gmail/@decocms/runtime/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], "google-meet/@decocms/runtime/@decocms/bindings/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], diff --git a/google-gmail/package.json b/google-gmail/package.json index c105bf78..e2c14235 100644 --- a/google-gmail/package.json +++ b/google-gmail/package.json @@ -12,7 +12,8 @@ "check": "tsc --noEmit" }, "dependencies": { - "@decocms/runtime": "1.2.5", + "@decocms/bindings": "^1.4.0", + "@decocms/runtime": "1.5.0", "zod": "^4.0.0" }, "devDependencies": { diff --git a/google-gmail/server/constants.ts b/google-gmail/server/constants.ts index bbaa19db..d3a83ee3 100644 --- a/google-gmail/server/constants.ts +++ b/google-gmail/server/constants.ts @@ -41,6 +41,16 @@ export const ENDPOINTS = { DRAFT: (draftId: string) => `${GMAIL_API_BASE}/drafts/${encodeURIComponent(draftId)}`, DRAFT_SEND: `${GMAIL_API_BASE}/drafts/send`, + + // Push notifications + WATCH: `${GMAIL_API_BASE}/watch`, + STOP: `${GMAIL_API_BASE}/stop`, + + // History + HISTORY: `${GMAIL_API_BASE}/history`, + + // Profile + PROFILE: `${GMAIL_API_BASE}/profile`, }; // Default pagination diff --git a/google-gmail/server/lib/email-connection-map.ts b/google-gmail/server/lib/email-connection-map.ts new file mode 100644 index 00000000..96c9d98d --- /dev/null +++ b/google-gmail/server/lib/email-connection-map.ts @@ -0,0 +1,23 @@ +/** + * In-memory mapping from Gmail email address to Mesh connection ID. + * + * Populated during onChange when we fetch the user's Gmail profile. + */ + +const emailMap = new Map(); + +export function setEmailMapping(email: string, connectionId: string): void { + emailMap.set(email.toLowerCase(), connectionId); +} + +export function getConnectionForEmail(email: string): string | undefined { + return emailMap.get(email.toLowerCase()); +} + +export function removeConnectionMappings(connectionId: string): void { + for (const [email, connId] of emailMap) { + if (connId === connectionId) { + emailMap.delete(email); + } + } +} diff --git a/google-gmail/server/lib/trigger-store.ts b/google-gmail/server/lib/trigger-store.ts new file mode 100644 index 00000000..413f0b88 --- /dev/null +++ b/google-gmail/server/lib/trigger-store.ts @@ -0,0 +1,22 @@ +import { createTriggers } from "@decocms/runtime/triggers"; +import { StudioKV } from "@decocms/runtime/trigger-storage"; +import { z } from "zod"; + +const storage = + process.env.MESH_URL && process.env.MESH_API_KEY + ? new StudioKV({ + url: process.env.MESH_URL, + apiKey: process.env.MESH_API_KEY, + }) + : undefined; + +export const triggers = createTriggers({ + definitions: [ + { + type: "gmail.message.received", + description: "Triggered when a new email is received", + params: z.object({}), + }, + ], + storage, +}); diff --git a/google-gmail/server/main.ts b/google-gmail/server/main.ts index a6be8797..48c2cf78 100644 --- a/google-gmail/server/main.ts +++ b/google-gmail/server/main.ts @@ -9,13 +9,20 @@ import { serve } from "@decocms/mcps-shared/serve"; import { createGoogleOAuth } from "@decocms/mcps-shared/google-oauth"; import { tools } from "./tools/index.ts"; -import { GOOGLE_SCOPES } from "./constants.ts"; +import { ENDPOINTS, GOOGLE_SCOPES } from "./constants.ts"; import type { Env } from "../shared/deco.gen.ts"; +import { + setEmailMapping, + removeConnectionMappings, +} from "./lib/email-connection-map.ts"; +import { handleGmailWebhook } from "./webhook.ts"; export type { Env }; +const GMAIL_PUBSUB_TOPIC = process.env.GMAIL_PUBSUB_TOPIC || ""; + const runtime = withRuntime({ - tools: (env: Env) => tools.map((createTool) => createTool(env)), + tools: tools as any, oauth: createGoogleOAuth({ scopes: [ GOOGLE_SCOPES.GMAIL_READONLY, @@ -24,6 +31,87 @@ const runtime = withRuntime({ GOOGLE_SCOPES.GMAIL_LABELS, ], }), + configuration: { + onChange: async (env) => { + const token = env.MESH_REQUEST_CONTEXT?.authorization; + const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId; + if (!token || !connectionId) return; + + // Strip Bearer prefix if present + const accessToken = token.replace(/^Bearer\s+/i, ""); + + try { + // Fetch user profile to get email address + const profileRes = await fetch(ENDPOINTS.PROFILE, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!profileRes.ok) { + console.error( + `[Gmail onChange] Failed to fetch profile: ${profileRes.status}`, + ); + return; + } + + const profile = (await profileRes.json()) as { + emailAddress: string; + historyId: string; + }; + + // Map email → connectionId + removeConnectionMappings(connectionId); + setEmailMapping(profile.emailAddress, connectionId); + console.log( + `[Gmail onChange] Mapped ${profile.emailAddress} → ${connectionId}`, + ); + + // Set up Gmail push notifications via users.watch() + if (GMAIL_PUBSUB_TOPIC) { + const watchRes = await fetch(ENDPOINTS.WATCH, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + topicName: GMAIL_PUBSUB_TOPIC, + labelIds: ["INBOX"], + }), + }); + + if (watchRes.ok) { + const watchData = (await watchRes.json()) as { + historyId: string; + expiration: string; + }; + console.log( + `[Gmail onChange] Watch registered for ${profile.emailAddress}, expires ${watchData.expiration}`, + ); + } else { + const error = await watchRes.text(); + console.error( + `[Gmail onChange] Failed to register watch: ${watchRes.status} - ${error}`, + ); + } + } + } catch (error) { + console.error("[Gmail onChange] Error:", error); + } + }, + }, }); -serve(runtime.fetch); +/** + * Wrap runtime.fetch to intercept Gmail webhook requests. + */ +const wrappedFetch: typeof runtime.fetch = async (req, env, ctx) => { + const url = new URL(req.url); + + if (req.method === "POST" && url.pathname === "/webhooks/gmail") { + return handleGmailWebhook(req); + } + + return runtime.fetch(req, env, ctx); +}; + +serve(wrappedFetch); diff --git a/google-gmail/server/tools/index.ts b/google-gmail/server/tools/index.ts index e9647d8d..4422eabe 100644 --- a/google-gmail/server/tools/index.ts +++ b/google-gmail/server/tools/index.ts @@ -9,12 +9,14 @@ * - threadTools: Thread management (list, get, trash, untrash, modify, delete) * - labelTools: Label management (list, get, create, update, delete) * - draftTools: Draft management (list, get, create, update, send, delete) + * - triggers: TRIGGER_LIST / TRIGGER_CONFIGURE for Mesh automations */ import { messageTools } from "./messages.ts"; import { threadTools } from "./threads.ts"; import { labelTools } from "./labels.ts"; import { draftTools } from "./drafts.ts"; +import { triggers } from "../lib/trigger-store.ts"; // Export all tools from all modules export const tools = [ @@ -26,4 +28,6 @@ export const tools = [ ...labelTools, // Draft management tools ...draftTools, + // Trigger tools (register/unregister event triggers) + () => triggers.tools(), ]; diff --git a/google-gmail/server/webhook.ts b/google-gmail/server/webhook.ts new file mode 100644 index 00000000..adedb44d --- /dev/null +++ b/google-gmail/server/webhook.ts @@ -0,0 +1,72 @@ +/** + * Gmail Webhook HTTP Handler + * + * Receives Google Pub/Sub push notifications for Gmail mailbox changes + * and routes them to the correct connection via triggers.notify(). + */ + +import { getConnectionForEmail } from "./lib/email-connection-map.ts"; +import { triggers } from "./lib/trigger-store.ts"; + +interface PubSubPushMessage { + message: { + data: string; // base64-encoded JSON: { emailAddress, historyId } + messageId: string; + publishTime: string; + }; + subscription: string; +} + +interface GmailNotification { + emailAddress: string; + historyId: string; +} + +export async function handleGmailWebhook(req: Request): Promise { + let body: PubSubPushMessage; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!body.message?.data) { + return Response.json({ error: "Missing message data" }, { status: 400 }); + } + + let notification: GmailNotification; + try { + const decoded = Buffer.from(body.message.data, "base64").toString("utf-8"); + notification = JSON.parse(decoded); + } catch { + return Response.json( + { error: "Invalid notification data" }, + { status: 400 }, + ); + } + + const { emailAddress, historyId } = notification; + if (!emailAddress) { + return Response.json({ ok: true, skipped: "no_email_address" }); + } + + const connectionId = getConnectionForEmail(emailAddress); + if (!connectionId) { + console.log( + `[Gmail Webhook] No connection mapping for ${emailAddress}, skipping`, + ); + return Response.json({ ok: true, skipped: "no_mapping" }); + } + + console.log( + `[Gmail Webhook] Notification for ${emailAddress} (historyId: ${historyId}) → connection ${connectionId}`, + ); + + triggers.notify(connectionId, "gmail.message.received", { + event: "gmail.message.received", + emailAddress, + historyId, + }); + + return Response.json({ ok: true, event: "gmail.message.received" }); +} From 3a6eea0e3def1ded536109aabc708c7e98bd35ce Mon Sep 17 00:00:00 2001 From: guitavano Date: Mon, 27 Apr 2026 22:40:16 -0300 Subject: [PATCH 2/3] feat(google-gmail): migrate to Cloudflare Workers with KV storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace in-memory email→connectionId mapping with Cloudflare KV for persistence across Worker isolates and restarts. Add wrangler.toml, vite build config, and update deploy.json for cloudflare-workers platform. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 10 ++++- deploy.json | 9 +++++ google-gmail/package.json | 14 ++++--- .../server/lib/email-connection-map.ts | 38 +++++++++++++------ google-gmail/server/main.ts | 23 +++++------ google-gmail/server/webhook.ts | 9 +++-- google-gmail/shared/deco.gen.ts | 2 + google-gmail/tsconfig.json | 3 +- google-gmail/vite.config.ts | 26 +++++++++++++ google-gmail/wrangler.toml | 12 ++++++ 10 files changed, 111 insertions(+), 35 deletions(-) create mode 100644 google-gmail/vite.config.ts create mode 100644 google-gmail/wrangler.toml diff --git a/bun.lock b/bun.lock index 9ae8976c..3fae0b08 100644 --- a/bun.lock +++ b/bun.lock @@ -383,11 +383,15 @@ "zod": "^4.0.0", }, "devDependencies": { + "@cloudflare/vite-plugin": "^1.13.4", + "@cloudflare/workers-types": "^4.20251014.0", "@decocms/mcps-shared": "workspace:*", "@modelcontextprotocol/sdk": "1.25.1", - "bun-types": "^1.3.7", + "@types/node": "^25.6.0", "deco-cli": "^0.28.0", "typescript": "^5.7.2", + "vite": "7.2.0", + "wrangler": "^4.28.0", }, }, "google-meet": { @@ -4025,6 +4029,8 @@ "google-gmail/@decocms/runtime": ["@decocms/runtime@1.5.0", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.27.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" }, "peerDependencies": { "ai": ">=6.0.0" } }, "sha512-TVwuutWjghkDtt/6ylxXyUEgbjDA8xg9tjnMZ3kq3DO1RfhTuaAW0YKuf6AJPN+aYno89VSQ8HBVJ7phwIelpw=="], + "google-gmail/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "google-gmail/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "google-meet/@decocms/runtime": ["@decocms/runtime@1.2.5", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.1.1", "@modelcontextprotocol/sdk": "1.25.2", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-0s02lfj/O7nTAc7FTmFsA+lZpUDnapjQHnRYrQXItLKrbJvjSnfoq5V8HA1Npv5HelBvsVk7QQHaW8pSN/l37w=="], @@ -4521,6 +4527,8 @@ "google-gmail/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + "google-gmail/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "google-meet/@decocms/runtime/@decocms/bindings": ["@decocms/bindings@1.2.0", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.3", "@tanstack/react-router": "1.139.7", "react": "^19.2.0", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-+4/VOOVERB8UixGKmN0VkLazxeMAahbG0A9xOYTPL+MJIAM30htrLG2aHI2Dm5ASgccAD4bW5RuLqv2PDFZZPA=="], "google-meet/@decocms/runtime/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="], diff --git a/deploy.json b/deploy.json index 82861f09..54b87c1e 100644 --- a/deploy.json +++ b/deploy.json @@ -430,5 +430,14 @@ "veo/**", "shared/**" ] + }, + "google-gmail": { + "site": "google-gmail", + "entrypoint": "./dist/server/main.js", + "platformName": "cloudflare-workers", + "watch": [ + "google-gmail/**", + "shared/**" + ] } } diff --git a/google-gmail/package.json b/google-gmail/package.json index e2c14235..a3e0fd24 100644 --- a/google-gmail/package.json +++ b/google-gmail/package.json @@ -5,9 +5,9 @@ "private": true, "type": "module", "scripts": { - "dev": "bun run --hot server/main.ts", - "build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js", - "build": "bun run build:server", + "dev": "deco dev --vite", + "build": "bun --bun vite build", + "deploy": "npm run build && deco deploy ./dist/server", "publish": "cat app.json | deco registry publish -w /shared/deco -y", "check": "tsc --noEmit" }, @@ -17,11 +17,15 @@ "zod": "^4.0.0" }, "devDependencies": { + "@cloudflare/vite-plugin": "^1.13.4", + "@cloudflare/workers-types": "^4.20251014.0", "@decocms/mcps-shared": "workspace:*", "@modelcontextprotocol/sdk": "1.25.1", - "bun-types": "^1.3.7", + "@types/node": "^25.6.0", "deco-cli": "^0.28.0", - "typescript": "^5.7.2" + "typescript": "^5.7.2", + "vite": "7.2.0", + "wrangler": "^4.28.0" }, "engines": { "node": ">=22.0.0" diff --git a/google-gmail/server/lib/email-connection-map.ts b/google-gmail/server/lib/email-connection-map.ts index 96c9d98d..be272d51 100644 --- a/google-gmail/server/lib/email-connection-map.ts +++ b/google-gmail/server/lib/email-connection-map.ts @@ -1,23 +1,39 @@ /** - * In-memory mapping from Gmail email address to Mesh connection ID. + * KV-backed mapping from Gmail email address to Mesh connection ID. * - * Populated during onChange when we fetch the user's Gmail profile. + * Uses Cloudflare KV (EMAIL_MAP binding) for persistence across + * Worker isolates and restarts. */ -const emailMap = new Map(); +const KV_PREFIX = "email:"; -export function setEmailMapping(email: string, connectionId: string): void { - emailMap.set(email.toLowerCase(), connectionId); +export async function setEmailMapping( + kv: KVNamespace, + email: string, + connectionId: string, +): Promise { + await kv.put(`${KV_PREFIX}${email.toLowerCase()}`, connectionId); } -export function getConnectionForEmail(email: string): string | undefined { - return emailMap.get(email.toLowerCase()); +export async function getConnectionForEmail( + kv: KVNamespace, + email: string, +): Promise { + const value = await kv.get(`${KV_PREFIX}${email.toLowerCase()}`); + return value ?? undefined; } -export function removeConnectionMappings(connectionId: string): void { - for (const [email, connId] of emailMap) { - if (connId === connectionId) { - emailMap.delete(email); +export async function removeConnectionMappings( + kv: KVNamespace, + connectionId: string, +): Promise { + const listed = await kv.list({ prefix: KV_PREFIX }); + const deletes: Promise[] = []; + for (const key of listed.keys) { + const value = await kv.get(key.name); + if (value === connectionId) { + deletes.push(kv.delete(key.name)); } } + await Promise.all(deletes); } diff --git a/google-gmail/server/main.ts b/google-gmail/server/main.ts index 48c2cf78..3c0ffe74 100644 --- a/google-gmail/server/main.ts +++ b/google-gmail/server/main.ts @@ -3,9 +3,10 @@ * * This MCP provides tools for interacting with Gmail API, * including message management, thread operations, labels, and drafts. + * + * Deployed as a Cloudflare Worker with KV for email→connection mappings. */ import { withRuntime } from "@decocms/runtime"; -import { serve } from "@decocms/mcps-shared/serve"; import { createGoogleOAuth } from "@decocms/mcps-shared/google-oauth"; import { tools } from "./tools/index.ts"; @@ -19,8 +20,6 @@ import { handleGmailWebhook } from "./webhook.ts"; export type { Env }; -const GMAIL_PUBSUB_TOPIC = process.env.GMAIL_PUBSUB_TOPIC || ""; - const runtime = withRuntime({ tools: tools as any, oauth: createGoogleOAuth({ @@ -37,11 +36,10 @@ const runtime = withRuntime({ const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId; if (!token || !connectionId) return; - // Strip Bearer prefix if present const accessToken = token.replace(/^Bearer\s+/i, ""); + const kv = env.EMAIL_MAP; try { - // Fetch user profile to get email address const profileRes = await fetch(ENDPOINTS.PROFILE, { headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -58,15 +56,14 @@ const runtime = withRuntime({ historyId: string; }; - // Map email → connectionId - removeConnectionMappings(connectionId); - setEmailMapping(profile.emailAddress, connectionId); + await removeConnectionMappings(kv, connectionId); + await setEmailMapping(kv, profile.emailAddress, connectionId); console.log( `[Gmail onChange] Mapped ${profile.emailAddress} → ${connectionId}`, ); - // Set up Gmail push notifications via users.watch() - if (GMAIL_PUBSUB_TOPIC) { + const pubsubTopic = process.env.GMAIL_PUBSUB_TOPIC || ""; + if (pubsubTopic) { const watchRes = await fetch(ENDPOINTS.WATCH, { method: "POST", headers: { @@ -74,7 +71,7 @@ const runtime = withRuntime({ "Content-Type": "application/json", }, body: JSON.stringify({ - topicName: GMAIL_PUBSUB_TOPIC, + topicName: pubsubTopic, labelIds: ["INBOX"], }), }); @@ -108,10 +105,10 @@ const wrappedFetch: typeof runtime.fetch = async (req, env, ctx) => { const url = new URL(req.url); if (req.method === "POST" && url.pathname === "/webhooks/gmail") { - return handleGmailWebhook(req); + return handleGmailWebhook(req, env.EMAIL_MAP); } return runtime.fetch(req, env, ctx); }; -serve(wrappedFetch); +export default { fetch: wrappedFetch }; diff --git a/google-gmail/server/webhook.ts b/google-gmail/server/webhook.ts index adedb44d..6ec01aa2 100644 --- a/google-gmail/server/webhook.ts +++ b/google-gmail/server/webhook.ts @@ -22,7 +22,10 @@ interface GmailNotification { historyId: string; } -export async function handleGmailWebhook(req: Request): Promise { +export async function handleGmailWebhook( + req: Request, + kv: KVNamespace, +): Promise { let body: PubSubPushMessage; try { body = await req.json(); @@ -36,7 +39,7 @@ export async function handleGmailWebhook(req: Request): Promise { let notification: GmailNotification; try { - const decoded = Buffer.from(body.message.data, "base64").toString("utf-8"); + const decoded = atob(body.message.data); notification = JSON.parse(decoded); } catch { return Response.json( @@ -50,7 +53,7 @@ export async function handleGmailWebhook(req: Request): Promise { return Response.json({ ok: true, skipped: "no_email_address" }); } - const connectionId = getConnectionForEmail(emailAddress); + const connectionId = await getConnectionForEmail(kv, emailAddress); if (!connectionId) { console.log( `[Gmail Webhook] No connection mapping for ${emailAddress}, skipping`, diff --git a/google-gmail/shared/deco.gen.ts b/google-gmail/shared/deco.gen.ts index dd7e6292..60d0a7e1 100644 --- a/google-gmail/shared/deco.gen.ts +++ b/google-gmail/shared/deco.gen.ts @@ -36,6 +36,8 @@ export interface Env { SELF?: unknown; /** Whether running locally */ IS_LOCAL?: boolean; + /** Cloudflare KV namespace for email → connectionId mappings */ + EMAIL_MAP: KVNamespace; } /** diff --git a/google-gmail/tsconfig.json b/google-gmail/tsconfig.json index 70f5415a..6bee74ad 100644 --- a/google-gmail/tsconfig.json +++ b/google-gmail/tsconfig.json @@ -19,7 +19,7 @@ "paths": { "@decocms/mcps-shared/*": ["../shared/*"] }, - "types": ["bun-types"] + "types": ["@cloudflare/workers-types", "node"] }, "include": [ "server/**/*.ts", @@ -30,4 +30,3 @@ "dist" ] } - diff --git a/google-gmail/vite.config.ts b/google-gmail/vite.config.ts new file mode 100644 index 00000000..41149bc4 --- /dev/null +++ b/google-gmail/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vite"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import deco from "@decocms/mcps-shared/vite-plugin"; + +const VITE_SERVER_ENVIRONMENT_NAME = "server"; + +export default defineConfig({ + plugins: [ + cloudflare({ + configPath: "wrangler.toml", + viteEnvironment: { + name: VITE_SERVER_ENVIRONMENT_NAME, + }, + }), + deco(), + ], + + define: { + "process.env.NODE_ENV": JSON.stringify( + process.env.NODE_ENV || "development", + ), + global: "globalThis", + }, + + cacheDir: "node_modules/.vite", +}); diff --git a/google-gmail/wrangler.toml b/google-gmail/wrangler.toml new file mode 100644 index 00000000..c4462e79 --- /dev/null +++ b/google-gmail/wrangler.toml @@ -0,0 +1,12 @@ +#:schema node_modules/wrangler/config-schema.json +name = "google-gmail" +main = "server/main.ts" +compatibility_date = "2025-06-17" +compatibility_flags = [ "nodejs_compat" ] + +[[kv_namespaces]] +binding = "EMAIL_MAP" +id = "PLACEHOLDER_KV_NAMESPACE_ID" + +[vars] +GMAIL_PUBSUB_TOPIC = "" From 8f060027143ff22c73cc2642ff49637229f317ec Mon Sep 17 00:00:00 2001 From: guitavano Date: Tue, 28 Apr 2026 08:43:29 -0300 Subject: [PATCH 3/3] fix(google-gmail): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace KV scan with reverse index (conn: → email) for O(1) cleanup - Defer trigger store initialization to first request (avoids process.env at module top level in CF Workers) - Add webhook secret validation via ?token= query param - Remove unnecessary `as any` cast on tools array Co-Authored-By: Claude Opus 4.6 --- .../server/lib/email-connection-map.ts | 30 +++++++----- google-gmail/server/lib/trigger-store.ts | 48 ++++++++++++------- google-gmail/server/main.ts | 7 +-- google-gmail/server/webhook.ts | 13 +++++ google-gmail/wrangler.toml | 4 ++ 5 files changed, 70 insertions(+), 32 deletions(-) diff --git a/google-gmail/server/lib/email-connection-map.ts b/google-gmail/server/lib/email-connection-map.ts index be272d51..0ebe3c09 100644 --- a/google-gmail/server/lib/email-connection-map.ts +++ b/google-gmail/server/lib/email-connection-map.ts @@ -3,23 +3,32 @@ * * Uses Cloudflare KV (EMAIL_MAP binding) for persistence across * Worker isolates and restarts. + * + * Stores two keys per mapping for O(1) lookup in both directions: + * email: → connectionId + * conn: → email address */ -const KV_PREFIX = "email:"; +const EMAIL_PREFIX = "email:"; +const CONN_PREFIX = "conn:"; export async function setEmailMapping( kv: KVNamespace, email: string, connectionId: string, ): Promise { - await kv.put(`${KV_PREFIX}${email.toLowerCase()}`, connectionId); + const normalizedEmail = email.toLowerCase(); + await Promise.all([ + kv.put(`${EMAIL_PREFIX}${normalizedEmail}`, connectionId), + kv.put(`${CONN_PREFIX}${connectionId}`, normalizedEmail), + ]); } export async function getConnectionForEmail( kv: KVNamespace, email: string, ): Promise { - const value = await kv.get(`${KV_PREFIX}${email.toLowerCase()}`); + const value = await kv.get(`${EMAIL_PREFIX}${email.toLowerCase()}`); return value ?? undefined; } @@ -27,13 +36,10 @@ export async function removeConnectionMappings( kv: KVNamespace, connectionId: string, ): Promise { - const listed = await kv.list({ prefix: KV_PREFIX }); - const deletes: Promise[] = []; - for (const key of listed.keys) { - const value = await kv.get(key.name); - if (value === connectionId) { - deletes.push(kv.delete(key.name)); - } - } - await Promise.all(deletes); + const email = await kv.get(`${CONN_PREFIX}${connectionId}`); + if (!email) return; + await Promise.all([ + kv.delete(`${EMAIL_PREFIX}${email}`), + kv.delete(`${CONN_PREFIX}${connectionId}`), + ]); } diff --git a/google-gmail/server/lib/trigger-store.ts b/google-gmail/server/lib/trigger-store.ts index 413f0b88..6bd13dcc 100644 --- a/google-gmail/server/lib/trigger-store.ts +++ b/google-gmail/server/lib/trigger-store.ts @@ -2,21 +2,35 @@ import { createTriggers } from "@decocms/runtime/triggers"; import { StudioKV } from "@decocms/runtime/trigger-storage"; import { z } from "zod"; -const storage = - process.env.MESH_URL && process.env.MESH_API_KEY - ? new StudioKV({ - url: process.env.MESH_URL, - apiKey: process.env.MESH_API_KEY, - }) - : undefined; +let instance: ReturnType | undefined; -export const triggers = createTriggers({ - definitions: [ - { - type: "gmail.message.received", - description: "Triggered when a new email is received", - params: z.object({}), - }, - ], - storage, -}); +function getTriggers(): ReturnType { + if (instance) return instance; + + const storage = + process.env.MESH_URL && process.env.MESH_API_KEY + ? new StudioKV({ + url: process.env.MESH_URL, + apiKey: process.env.MESH_API_KEY, + }) + : undefined; + + instance = createTriggers({ + definitions: [ + { + type: "gmail.message.received", + description: "Triggered when a new email is received", + params: z.object({}), + }, + ], + storage, + }); + + return instance; +} + +export const triggers = { + tools: () => getTriggers().tools(), + notify: (...args: Parameters["notify"]>) => + getTriggers().notify(...args), +}; diff --git a/google-gmail/server/main.ts b/google-gmail/server/main.ts index 3c0ffe74..36b22759 100644 --- a/google-gmail/server/main.ts +++ b/google-gmail/server/main.ts @@ -21,7 +21,7 @@ import { handleGmailWebhook } from "./webhook.ts"; export type { Env }; const runtime = withRuntime({ - tools: tools as any, + tools, oauth: createGoogleOAuth({ scopes: [ GOOGLE_SCOPES.GMAIL_READONLY, @@ -104,8 +104,9 @@ const runtime = withRuntime({ const wrappedFetch: typeof runtime.fetch = async (req, env, ctx) => { const url = new URL(req.url); - if (req.method === "POST" && url.pathname === "/webhooks/gmail") { - return handleGmailWebhook(req, env.EMAIL_MAP); + if (req.method === "POST" && url.pathname.startsWith("/webhooks/gmail")) { + const webhookSecret = process.env.GMAIL_WEBHOOK_SECRET || ""; + return handleGmailWebhook(req, env.EMAIL_MAP, webhookSecret); } return runtime.fetch(req, env, ctx); diff --git a/google-gmail/server/webhook.ts b/google-gmail/server/webhook.ts index 6ec01aa2..20426def 100644 --- a/google-gmail/server/webhook.ts +++ b/google-gmail/server/webhook.ts @@ -3,6 +3,10 @@ * * Receives Google Pub/Sub push notifications for Gmail mailbox changes * and routes them to the correct connection via triggers.notify(). + * + * Authentication: Pub/Sub push subscriptions should be configured with + * ?token= as a query parameter. This handler + * validates the token before processing. */ import { getConnectionForEmail } from "./lib/email-connection-map.ts"; @@ -25,7 +29,16 @@ interface GmailNotification { export async function handleGmailWebhook( req: Request, kv: KVNamespace, + webhookSecret: string, ): Promise { + if (webhookSecret) { + const url = new URL(req.url); + const token = url.searchParams.get("token"); + if (token !== webhookSecret) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + } + let body: PubSubPushMessage; try { body = await req.json(); diff --git a/google-gmail/wrangler.toml b/google-gmail/wrangler.toml index c4462e79..d049f971 100644 --- a/google-gmail/wrangler.toml +++ b/google-gmail/wrangler.toml @@ -10,3 +10,7 @@ id = "PLACEHOLDER_KV_NAMESPACE_ID" [vars] GMAIL_PUBSUB_TOPIC = "" + +# Set via `wrangler secret put GMAIL_WEBHOOK_SECRET` +# Then configure Pub/Sub push subscription URL as: +# https:///webhooks/gmail?token=