diff --git a/bun.lock b/bun.lock index 06e65945..3fae0b08 100644 --- a/bun.lock +++ b/bun.lock @@ -378,15 +378,20 @@ "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": { + "@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": { @@ -4020,7 +4025,11 @@ "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/@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=="], @@ -4514,9 +4523,11 @@ "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.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/@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=="], @@ -4986,7 +4997,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/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 c105bf78..a3e0fd24 100644 --- a/google-gmail/package.json +++ b/google-gmail/package.json @@ -5,22 +5,27 @@ "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" }, "dependencies": { - "@decocms/runtime": "1.2.5", + "@decocms/bindings": "^1.4.0", + "@decocms/runtime": "1.5.0", "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/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..0ebe3c09 --- /dev/null +++ b/google-gmail/server/lib/email-connection-map.ts @@ -0,0 +1,45 @@ +/** + * KV-backed mapping from Gmail email address to Mesh connection ID. + * + * 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 EMAIL_PREFIX = "email:"; +const CONN_PREFIX = "conn:"; + +export async function setEmailMapping( + kv: KVNamespace, + email: string, + connectionId: string, +): Promise { + 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(`${EMAIL_PREFIX}${email.toLowerCase()}`); + return value ?? undefined; +} + +export async function removeConnectionMappings( + kv: KVNamespace, + connectionId: string, +): Promise { + 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 new file mode 100644 index 00000000..6bd13dcc --- /dev/null +++ b/google-gmail/server/lib/trigger-store.ts @@ -0,0 +1,36 @@ +import { createTriggers } from "@decocms/runtime/triggers"; +import { StudioKV } from "@decocms/runtime/trigger-storage"; +import { z } from "zod"; + +let instance: ReturnType | undefined; + +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 a6be8797..36b22759 100644 --- a/google-gmail/server/main.ts +++ b/google-gmail/server/main.ts @@ -3,19 +3,25 @@ * * 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"; -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 runtime = withRuntime({ - tools: (env: Env) => tools.map((createTool) => createTool(env)), + tools, oauth: createGoogleOAuth({ scopes: [ GOOGLE_SCOPES.GMAIL_READONLY, @@ -24,6 +30,86 @@ 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; + + const accessToken = token.replace(/^Bearer\s+/i, ""); + const kv = env.EMAIL_MAP; + + try { + 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; + }; + + await removeConnectionMappings(kv, connectionId); + await setEmailMapping(kv, profile.emailAddress, connectionId); + console.log( + `[Gmail onChange] Mapped ${profile.emailAddress} → ${connectionId}`, + ); + + const pubsubTopic = process.env.GMAIL_PUBSUB_TOPIC || ""; + if (pubsubTopic) { + const watchRes = await fetch(ENDPOINTS.WATCH, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + topicName: pubsubTopic, + 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.startsWith("/webhooks/gmail")) { + const webhookSecret = process.env.GMAIL_WEBHOOK_SECRET || ""; + return handleGmailWebhook(req, env.EMAIL_MAP, webhookSecret); + } + + return runtime.fetch(req, env, ctx); +}; + +export default { fetch: 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..20426def --- /dev/null +++ b/google-gmail/server/webhook.ts @@ -0,0 +1,88 @@ +/** + * Gmail Webhook HTTP Handler + * + * 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"; +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, + 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(); + } 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 = atob(body.message.data); + 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 = await getConnectionForEmail(kv, 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" }); +} 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..d049f971 --- /dev/null +++ b/google-gmail/wrangler.toml @@ -0,0 +1,16 @@ +#: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 = "" + +# Set via `wrangler secret put GMAIL_WEBHOOK_SECRET` +# Then configure Pub/Sub push subscription URL as: +# https:///webhooks/gmail?token=