Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions deploy.json
Original file line number Diff line number Diff line change
Expand Up @@ -430,5 +430,14 @@
"veo/**",
"shared/**"
]
},
"google-gmail": {
"site": "google-gmail",
"entrypoint": "./dist/server/main.js",
"platformName": "cloudflare-workers",
"watch": [
"google-gmail/**",
"shared/**"
]
}
}
17 changes: 11 additions & 6 deletions google-gmail/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions google-gmail/server/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions google-gmail/server/lib/email-connection-map.ts
Original file line number Diff line number Diff line change
@@ -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:<addr> → connectionId
* conn:<connId> → email address
*/

const EMAIL_PREFIX = "email:";
const CONN_PREFIX = "conn:";

export async function setEmailMapping(
kv: KVNamespace,
email: string,
connectionId: string,
): Promise<void> {
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<string | undefined> {
const value = await kv.get(`${EMAIL_PREFIX}${email.toLowerCase()}`);
return value ?? undefined;
}

export async function removeConnectionMappings(
kv: KVNamespace,
connectionId: string,
): Promise<void> {
const email = await kv.get(`${CONN_PREFIX}${connectionId}`);
if (!email) return;
await Promise.all([
kv.delete(`${EMAIL_PREFIX}${email}`),
kv.delete(`${CONN_PREFIX}${connectionId}`),
]);
}
36 changes: 36 additions & 0 deletions google-gmail/server/lib/trigger-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { createTriggers } from "@decocms/runtime/triggers";
import { StudioKV } from "@decocms/runtime/trigger-storage";
import { z } from "zod";

let instance: ReturnType<typeof createTriggers> | undefined;

function getTriggers(): ReturnType<typeof createTriggers> {
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<ReturnType<typeof createTriggers>["notify"]>) =>
getTriggers().notify(...args),
};
94 changes: 90 additions & 4 deletions google-gmail/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Env>({
tools: (env: Env) => tools.map((createTool) => createTool(env)),
tools,
oauth: createGoogleOAuth({
scopes: [
GOOGLE_SCOPES.GMAIL_READONLY,
Expand All @@ -24,6 +30,86 @@ const runtime = withRuntime<Env>({
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 };
4 changes: 4 additions & 0 deletions google-gmail/server/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -26,4 +28,6 @@ export const tools = [
...labelTools,
// Draft management tools
...draftTools,
// Trigger tools (register/unregister event triggers)
() => triggers.tools(),
];
Loading
Loading