cursorbyemail 0.5.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,176 @@
1
+ # cursorbyemail
2
+
3
+ Email-based conversations with Cursor agents. Get notified via email when your Cursor agent finishes, and reply to continue the conversation.
4
+
5
+ ## How it works
6
+
7
+ 1. **Cursor hook** - When the Cursor agent finishes a task, a hook sends you an email with the agent's response
8
+ 2. **Reply via email** - Reply to the email with your follow-up prompt
9
+ 3. **Webhook receives reply** - Resend forwards your reply to a local webhook server exposed via ngrok
10
+ 4. **Agent resumes** - The webhook triggers `cursor agent --resume` to continue the conversation
11
+
12
+ ```
13
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
14
+ │ Cursor │────▶│ Resend │────▶│ Email │
15
+ │ Agent │ │ (send) │ │ Inbox │
16
+ └─────────────┘ └─────────────┘ └─────────────┘
17
+ ▲ │
18
+ │ │ reply
19
+ │ ▼
20
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
21
+ │ cursor │◀────│ Webhook │◀────│ Resend │
22
+ │ --resume │ │ + ngrok │ │ (receive) │
23
+ └─────────────┘ └─────────────┘ └─────────────┘
24
+ ```
25
+
26
+ ## Requirements
27
+
28
+ - Node.js 18+
29
+ - [Cursor CLI](https://cursor.com/es/docs/cli/installation) installed
30
+ - [Resend](https://resend.com) account with:
31
+ - API key
32
+ - Verified domain for sending
33
+ - Inbound email domain configured
34
+ - [ngrok](https://ngrok.com) account (free tier works)
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ npm install cursorbyemail
40
+ ```
41
+
42
+ ## Setup
43
+
44
+ Run the interactive setup wizard:
45
+
46
+ ```bash
47
+ npx cursorbyemail init
48
+ ```
49
+
50
+ The wizard will:
51
+
52
+ 1. Ask you to select your email provider (Resend)
53
+ 2. Ask you to select your tunnel provider (ngrok)
54
+ 3. Prompt for configuration values:
55
+ - `CURSOR_EMAIL_TO` - Your email address for notifications
56
+ - `CURSOR_EMAIL_SUBJECT` - Subject line prefix
57
+ - `RESEND_API_KEY` - Your Resend API key
58
+ - `RESEND_FROM` - Sender email address
59
+ - `RESEND_REPLY_TO` - Reply-to address (your Resend inbound domain)
60
+ - `NGROK_AUTHTOKEN` - ngrok auth token (optional if globally configured)
61
+ 4. Create configuration files:
62
+ - `.env` with your settings
63
+ - `.cursor/hooks.json` for Cursor hooks
64
+ - `.cursor/hooks/` with hook scripts
65
+ 5. Install required dependencies
66
+ 6. Verify Cursor CLI is installed
67
+
68
+ ## Usage
69
+
70
+ ### Start the webhook server
71
+
72
+ ```bash
73
+ npx cursorbyemail start
74
+ ```
75
+
76
+ This will:
77
+ - Start an Express server on port 8787 (configurable)
78
+ - Create an ngrok tunnel
79
+ - Display the webhook URL to configure in Resend
80
+
81
+ ### Configure Resend webhook
82
+
83
+ 1. Go to [Resend Dashboard](https://resend.com/webhooks)
84
+ 2. Add a new webhook endpoint
85
+ 3. Use the URL displayed by `cursorbyemail start`:
86
+ ```
87
+ https://<your-ngrok-url>/inbound/resend
88
+ ```
89
+ 4. Select the `email.received` event
90
+
91
+ ### Use with Cursor
92
+
93
+ Just use Cursor as normal. When the agent finishes a task:
94
+
95
+ 1. You'll receive an email with the agent's response
96
+ 2. Reply to the email with your follow-up
97
+ 3. The agent will resume and process your reply
98
+ 4. You'll receive another email with the new response
99
+
100
+ ## Configuration
101
+
102
+ ### Environment Variables
103
+
104
+ | Variable | Description | Required |
105
+ |----------|-------------|----------|
106
+ | `CURSOR_EMAIL_ON_FINISH` | Enable email notifications (`1` to enable) | Yes |
107
+ | `CURSOR_EMAIL_TO` | Email recipient | Yes |
108
+ | `CURSOR_EMAIL_SUBJECT` | Subject line prefix | No |
109
+ | `RESEND_API_KEY` | Resend API key | Yes |
110
+ | `RESEND_FROM` | Sender email | Yes |
111
+ | `RESEND_REPLY_TO` | Reply-to address | Yes |
112
+ | `NGROK_AUTHTOKEN` | ngrok authentication token | No |
113
+ | `WEBHOOK_PORT` | Port for webhook server (default: 8787) | No |
114
+ | `DEFAULT_REPO_PATH` | Default repository path for agent | No |
115
+ | `DRY_RUN` | Test mode without executing agent | No |
116
+ | `CURSOR_DRY_RUN` | Test email hook without sending | No |
117
+ | `CURSOR_HOOK_DEBUG` | Enable debug logging | No |
118
+
119
+ ### Customizing the hooks
120
+
121
+ The hook scripts are installed to `.cursor/hooks/`:
122
+
123
+ - `email-on-stop.mjs` - Sends email when agent finishes
124
+ - `webhook.mjs` - Receives inbound emails and resumes agent
125
+ - `beautify-transcript-email-html.js` - Formats emails
126
+
127
+ You can modify these files to customize behavior.
128
+
129
+ ## CLI Commands
130
+
131
+ ```bash
132
+ # Initialize in a project
133
+ npx cursorbyemail init
134
+
135
+ # Start webhook server with ngrok
136
+ npx cursorbyemail start
137
+
138
+ # Start on custom port
139
+ npx cursorbyemail start --port 3000
140
+ ```
141
+
142
+ ## Troubleshooting
143
+
144
+ ### Cursor CLI not found
145
+
146
+ Install the Cursor CLI:
147
+ https://cursor.com/es/docs/cli/installation
148
+
149
+ ### ngrok authentication error
150
+
151
+ Set your ngrok auth token:
152
+ ```bash
153
+ # Add to .env
154
+ NGROK_AUTHTOKEN=your_token_here
155
+
156
+ # Or configure globally
157
+ ngrok config add-authtoken your_token_here
158
+ ```
159
+
160
+ ### Emails not sending
161
+
162
+ Check your Resend configuration:
163
+ - Verify your domain is properly configured
164
+ - Ensure the `RESEND_FROM` address uses your verified domain
165
+ - Check the `.cursor/hooks/hook.log` file for errors
166
+
167
+ ### Webhook not receiving emails
168
+
169
+ Ensure:
170
+ - The ngrok tunnel is running (`npx cursorbyemail start`)
171
+ - The webhook URL is correctly configured in Resend
172
+ - The `email.received` event is selected in Resend
173
+
174
+ ## License
175
+
176
+ MIT
package/bin/cli.js ADDED
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { readFileSync } from "node:fs";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8"));
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name("cursorbyemail")
15
+ .description("Email-based conversations with Cursor agents")
16
+ .version(pkg.version);
17
+
18
+ program
19
+ .command("init")
20
+ .description("Initialize cursorbyemail in your project")
21
+ .action(async () => {
22
+ const { init } = await import("../lib/init.js");
23
+ await init();
24
+ });
25
+
26
+ program
27
+ .command("start")
28
+ .description("Start the webhook server with ngrok tunnel")
29
+ .option("-p, --port <port>", "Port for the webhook server", "8787")
30
+ .action(async (options) => {
31
+ const { start } = await import("../lib/start.js");
32
+ await start({ port: parseInt(options.port, 10) });
33
+ });
34
+
35
+ program.parse();
@@ -0,0 +1,22 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import chalk from "chalk";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export async function checkCursorCli() {
8
+ try {
9
+ const { stdout } = await execFileAsync("cursor", ["agent", "--version"], {
10
+ timeout: 10000,
11
+ });
12
+ const version = stdout.trim();
13
+ console.log(chalk.green(`✓ Cursor CLI found: ${version}`));
14
+ return true;
15
+ } catch (error) {
16
+ console.log(chalk.yellow("⚠ Cursor CLI not found or not working"));
17
+ console.log(chalk.dim(" The Cursor CLI is required for email reply functionality."));
18
+ console.log(chalk.dim(" Install it from: https://cursor.com/es/docs/cli/installation"));
19
+ console.log();
20
+ return false;
21
+ }
22
+ }
package/lib/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { init } from "./init.js";
2
+ export { start } from "./start.js";
3
+ export { checkCursorCli } from "./check-cursor.js";
package/lib/init.js ADDED
@@ -0,0 +1,221 @@
1
+ import prompts from "prompts";
2
+ import chalk from "chalk";
3
+ import { execSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { checkCursorCli } from "./check-cursor.js";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const templatesDir = join(__dirname, "..", "templates");
11
+
12
+ export async function init() {
13
+ console.log();
14
+ console.log(chalk.bold("cursorbyemail") + " - Email-based conversations with Cursor agents");
15
+ console.log(chalk.dim("─".repeat(60)));
16
+ console.log();
17
+
18
+ // Service selection
19
+ const serviceAnswers = await prompts([
20
+ {
21
+ type: "select",
22
+ name: "emailProvider",
23
+ message: "Select email provider:",
24
+ choices: [
25
+ { title: "Resend", value: "resend", description: "Transactional email API" },
26
+ ],
27
+ initial: 0,
28
+ },
29
+ {
30
+ type: "select",
31
+ name: "tunnelProvider",
32
+ message: "Select tunnel provider:",
33
+ choices: [
34
+ { title: "ngrok", value: "ngrok", description: "Secure tunnels to localhost" },
35
+ ],
36
+ initial: 0,
37
+ },
38
+ ]);
39
+
40
+ if (!serviceAnswers.emailProvider || !serviceAnswers.tunnelProvider) {
41
+ console.log(chalk.red("Setup cancelled."));
42
+ process.exit(1);
43
+ }
44
+
45
+ console.log();
46
+ console.log(chalk.bold("Environment Configuration"));
47
+ console.log(chalk.dim("These values will be stored in your .env file"));
48
+ console.log();
49
+
50
+ // Environment variables prompts
51
+ const envAnswers = await prompts([
52
+ {
53
+ type: "text",
54
+ name: "CURSOR_EMAIL_TO",
55
+ message: "Email address to receive notifications:",
56
+ validate: (v) => v.includes("@") || "Please enter a valid email address",
57
+ },
58
+ {
59
+ type: "text",
60
+ name: "CURSOR_EMAIL_SUBJECT",
61
+ message: "Email subject prefix:",
62
+ initial: "Cursor agent finished",
63
+ },
64
+ {
65
+ type: "password",
66
+ name: "RESEND_API_KEY",
67
+ message: "Resend API key (starts with re_):",
68
+ validate: (v) => v.startsWith("re_") || "Resend API keys start with 're_'",
69
+ },
70
+ {
71
+ type: "text",
72
+ name: "RESEND_FROM",
73
+ message: 'Sender email (e.g., "Agent <agent@domain.com>"):',
74
+ validate: (v) => v.includes("@") || "Please enter a valid sender email",
75
+ },
76
+ {
77
+ type: "text",
78
+ name: "RESEND_REPLY_TO",
79
+ message: 'Reply-to address for webhook (e.g., "domain@yoursubdomain.resend.app"):',
80
+ validate: (v) => v.includes("@") || "Please enter a valid reply-to address",
81
+ },
82
+ {
83
+ type: "text",
84
+ name: "NGROK_AUTHTOKEN",
85
+ message: "ngrok auth token (leave empty if globally configured):",
86
+ },
87
+ ]);
88
+
89
+ if (!envAnswers.CURSOR_EMAIL_TO || !envAnswers.RESEND_API_KEY) {
90
+ console.log(chalk.red("Setup cancelled."));
91
+ process.exit(1);
92
+ }
93
+
94
+ const cwd = process.cwd();
95
+
96
+ console.log();
97
+ console.log(chalk.bold("Setting up files..."));
98
+ console.log();
99
+
100
+ // Create .cursor/hooks directory
101
+ const cursorHooksDir = join(cwd, ".cursor", "hooks");
102
+ mkdirSync(cursorHooksDir, { recursive: true });
103
+ console.log(chalk.green("✓") + ` Created ${chalk.dim(".cursor/hooks/")}`);
104
+
105
+ // Copy hooks.json to .cursor/hooks.json
106
+ const hooksJsonSrc = join(templatesDir, "hooks.json");
107
+ const hooksJsonDest = join(cwd, ".cursor", "hooks.json");
108
+ copyFileSync(hooksJsonSrc, hooksJsonDest);
109
+ console.log(chalk.green("✓") + ` Created ${chalk.dim(".cursor/hooks.json")}`);
110
+
111
+ // Copy hook scripts
112
+ const hookFiles = [
113
+ "email-on-stop.mjs",
114
+ "beautify-transcript-email-html.js",
115
+ ];
116
+ for (const file of hookFiles) {
117
+ copyFileSync(join(templatesDir, file), join(cursorHooksDir, file));
118
+ console.log(chalk.green("✓") + ` Created ${chalk.dim(`.cursor/hooks/${file}`)}`);
119
+ }
120
+
121
+ // Copy webhook server
122
+ const webhookDest = join(cursorHooksDir, "webhook.mjs");
123
+ copyFileSync(join(templatesDir, "webhook.mjs"), webhookDest);
124
+ console.log(chalk.green("✓") + ` Created ${chalk.dim(".cursor/hooks/webhook.mjs")}`);
125
+
126
+ // Generate .env file
127
+ const envContent = `# cursorbyemail configuration
128
+ # Generated by npx cursorbyemail init
129
+
130
+ # Enable email notifications on agent finish
131
+ CURSOR_EMAIL_ON_FINISH=1
132
+
133
+ # Email recipient
134
+ CURSOR_EMAIL_TO="${envAnswers.CURSOR_EMAIL_TO}"
135
+
136
+ # Email subject prefix
137
+ CURSOR_EMAIL_SUBJECT="${envAnswers.CURSOR_EMAIL_SUBJECT}"
138
+
139
+ # Resend configuration
140
+ RESEND_API_KEY="${envAnswers.RESEND_API_KEY}"
141
+ RESEND_FROM="${envAnswers.RESEND_FROM}"
142
+ RESEND_REPLY_TO="${envAnswers.RESEND_REPLY_TO}"
143
+
144
+ # ngrok configuration (optional if globally configured)
145
+ ${envAnswers.NGROK_AUTHTOKEN ? `NGROK_AUTHTOKEN="${envAnswers.NGROK_AUTHTOKEN}"` : "# NGROK_AUTHTOKEN="}
146
+
147
+ # Webhook server port
148
+ WEBHOOK_PORT=8787
149
+
150
+ # Default repository path for webhook (defaults to cwd)
151
+ DEFAULT_REPO_PATH="${cwd}"
152
+ `;
153
+
154
+ const envPath = join(cwd, ".env");
155
+ const envExists = existsSync(envPath);
156
+
157
+ if (envExists) {
158
+ const existingEnv = readFileSync(envPath, "utf8");
159
+ writeFileSync(envPath, existingEnv + "\n" + envContent);
160
+ console.log(chalk.green("✓") + ` Appended to ${chalk.dim(".env")}`);
161
+ } else {
162
+ writeFileSync(envPath, envContent);
163
+ console.log(chalk.green("✓") + ` Created ${chalk.dim(".env")}`);
164
+ }
165
+
166
+ // Add script to package.json if it exists
167
+ const pkgPath = join(cwd, "package.json");
168
+ if (existsSync(pkgPath)) {
169
+ try {
170
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
171
+ pkg.scripts = pkg.scripts || {};
172
+ pkg.scripts["cursor:email-hook"] = "node .cursor/hooks/email-on-stop.mjs";
173
+ pkg.scripts["cursor:webhook"] = "node .cursor/hooks/webhook.mjs";
174
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
175
+ console.log(chalk.green("✓") + ` Added scripts to ${chalk.dim("package.json")}`);
176
+ } catch (e) {
177
+ console.log(chalk.yellow("⚠") + ` Could not update package.json: ${e.message}`);
178
+ }
179
+ }
180
+
181
+ console.log();
182
+ console.log(chalk.bold("Installing dependencies..."));
183
+ console.log();
184
+
185
+ // Install required dependencies
186
+ try {
187
+ execSync("npm install --save resend dotenv express", {
188
+ cwd,
189
+ stdio: "inherit",
190
+ });
191
+ console.log();
192
+ console.log(chalk.green("✓") + " Dependencies installed");
193
+ } catch (e) {
194
+ console.log(chalk.yellow("⚠") + " Failed to install dependencies. Run manually:");
195
+ console.log(chalk.dim(" npm install resend dotenv express"));
196
+ }
197
+
198
+ console.log();
199
+ console.log(chalk.bold("Checking Cursor CLI..."));
200
+ console.log();
201
+
202
+ // Check Cursor CLI
203
+ await checkCursorCli();
204
+
205
+ // Final instructions
206
+ console.log();
207
+ console.log(chalk.dim("─".repeat(60)));
208
+ console.log(chalk.bold.green("Setup complete!"));
209
+ console.log();
210
+ console.log("Next steps:");
211
+ console.log();
212
+ console.log(chalk.dim("1.") + " Configure Resend webhook to point to your ngrok URL:");
213
+ console.log(chalk.cyan(" npx cursorbyemail start"));
214
+ console.log();
215
+ console.log(chalk.dim("2.") + " In Resend dashboard, add webhook endpoint:");
216
+ console.log(chalk.dim(" https://<your-ngrok-url>/inbound/resend"));
217
+ console.log();
218
+ console.log(chalk.dim("3.") + " Start using Cursor - emails will be sent on agent completion");
219
+ console.log(chalk.dim(" and replies will resume the conversation."));
220
+ console.log();
221
+ }
package/lib/start.js ADDED
@@ -0,0 +1,132 @@
1
+ import "dotenv/config";
2
+ import chalk from "chalk";
3
+ import ngrok from "ngrok";
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ export async function start({ port = 8787 } = {}) {
9
+ const webhookPort = process.env.WEBHOOK_PORT || port;
10
+
11
+ console.log();
12
+ console.log(chalk.bold("cursorbyemail") + " - Starting webhook server");
13
+ console.log(chalk.dim("─".repeat(60)));
14
+ console.log();
15
+
16
+ // Find webhook script
17
+ const cwd = process.cwd();
18
+ const webhookPaths = [
19
+ join(cwd, ".cursor", "hooks", "webhook.mjs"),
20
+ join(cwd, "webhook.mjs"),
21
+ ];
22
+
23
+ let webhookPath = null;
24
+ for (const p of webhookPaths) {
25
+ if (existsSync(p)) {
26
+ webhookPath = p;
27
+ break;
28
+ }
29
+ }
30
+
31
+ if (!webhookPath) {
32
+ console.log(chalk.red("✗ webhook.mjs not found"));
33
+ console.log(chalk.dim(" Run 'npx cursorbyemail init' first to set up your project"));
34
+ process.exit(1);
35
+ }
36
+
37
+ console.log(chalk.dim(`Using webhook: ${webhookPath}`));
38
+ console.log();
39
+
40
+ // Start Express server
41
+ console.log(chalk.bold("Starting webhook server..."));
42
+
43
+ const serverProcess = spawn("node", [webhookPath], {
44
+ env: { ...process.env, WEBHOOK_PORT: String(webhookPort) },
45
+ stdio: ["inherit", "pipe", "pipe"],
46
+ cwd,
47
+ });
48
+
49
+ serverProcess.stdout.on("data", (data) => {
50
+ process.stdout.write(chalk.dim(`[server] ${data}`));
51
+ });
52
+
53
+ serverProcess.stderr.on("data", (data) => {
54
+ process.stderr.write(chalk.red(`[server] ${data}`));
55
+ });
56
+
57
+ serverProcess.on("error", (err) => {
58
+ console.log(chalk.red(`✗ Failed to start server: ${err.message}`));
59
+ process.exit(1);
60
+ });
61
+
62
+ // Wait for server to start
63
+ await new Promise((resolve) => setTimeout(resolve, 1500));
64
+
65
+ console.log(chalk.green("✓") + ` Webhook server running on port ${webhookPort}`);
66
+ console.log();
67
+
68
+ // Start ngrok tunnel
69
+ console.log(chalk.bold("Starting ngrok tunnel..."));
70
+
71
+ try {
72
+ const ngrokOptions = {
73
+ addr: webhookPort,
74
+ proto: "http",
75
+ };
76
+
77
+ // Use auth token from env if available
78
+ if (process.env.NGROK_AUTHTOKEN) {
79
+ ngrokOptions.authtoken = process.env.NGROK_AUTHTOKEN;
80
+ }
81
+
82
+ const url = await ngrok.connect(ngrokOptions);
83
+
84
+ console.log(chalk.green("✓") + " ngrok tunnel established");
85
+ console.log();
86
+ console.log(chalk.dim("─".repeat(60)));
87
+ console.log();
88
+ console.log(chalk.bold("Webhook URL:"));
89
+ console.log(chalk.cyan.bold(` ${url}/inbound/resend`));
90
+ console.log();
91
+ console.log(chalk.dim("Add this URL to your Resend webhook configuration:"));
92
+ console.log(chalk.dim(" 1. Go to Resend dashboard → Webhooks"));
93
+ console.log(chalk.dim(" 2. Add endpoint with URL above"));
94
+ console.log(chalk.dim(" 3. Select 'email.received' event"));
95
+ console.log();
96
+ console.log(chalk.dim("Press Ctrl+C to stop"));
97
+ console.log();
98
+
99
+ } catch (err) {
100
+ console.log(chalk.red(`✗ Failed to start ngrok: ${err.message}`));
101
+ console.log();
102
+
103
+ if (err.message.includes("authtoken")) {
104
+ console.log(chalk.dim("To fix this:"));
105
+ console.log(chalk.dim(" 1. Get your authtoken from https://dashboard.ngrok.com/get-started/your-authtoken"));
106
+ console.log(chalk.dim(" 2. Add NGROK_AUTHTOKEN to your .env file"));
107
+ console.log(chalk.dim(" Or run: ngrok config add-authtoken <token>"));
108
+ }
109
+
110
+ serverProcess.kill();
111
+ process.exit(1);
112
+ }
113
+
114
+ // Handle shutdown
115
+ const shutdown = async () => {
116
+ console.log();
117
+ console.log(chalk.dim("Shutting down..."));
118
+
119
+ try {
120
+ await ngrok.disconnect();
121
+ await ngrok.kill();
122
+ } catch (e) {
123
+ // Ignore ngrok shutdown errors
124
+ }
125
+
126
+ serverProcess.kill();
127
+ process.exit(0);
128
+ };
129
+
130
+ process.on("SIGINT", shutdown);
131
+ process.on("SIGTERM", shutdown);
132
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "cursorbyemail",
3
+ "version": "0.5.0",
4
+ "description": "Email-based conversations with Cursor agents via Resend webhooks and ngrok tunneling",
5
+ "type": "module",
6
+ "bin": {
7
+ "cursorbyemail": "bin/cli.js"
8
+ },
9
+ "main": "lib/index.js",
10
+ "scripts": {
11
+ "start": "node lib/start.js"
12
+ },
13
+ "keywords": [
14
+ "cursor",
15
+ "email",
16
+ "agent",
17
+ "resend",
18
+ "ngrok",
19
+ "webhook",
20
+ "ai",
21
+ "automation"
22
+ ],
23
+ "author": "",
24
+ "license": "MIT",
25
+ "dependencies": {
26
+ "chalk": "^5.3.0",
27
+ "commander": "^12.1.0",
28
+ "dotenv": "^16.4.5",
29
+ "express": "^4.21.0",
30
+ "ngrok": "^5.0.0-beta.2",
31
+ "prompts": "^2.4.2",
32
+ "resend": "^4.0.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ },
37
+ "files": [
38
+ "bin/",
39
+ "lib/",
40
+ "templates/"
41
+ ],
42
+ "repository": {
43
+ "type": "git",
44
+ "url": ""
45
+ }
46
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Beautify a Cursor transcript (raw text) into HTML suitable for email.
3
+ * - Handles "user:" / "assistant:" turns
4
+ * - Renders <user_query> blocks
5
+ * - Renders <code_selection ...> blocks with header + line numbers
6
+ * - Collapses <attached_files> placeholder
7
+ * - Escapes HTML, preserves spacing, wraps long code lines
8
+ *
9
+ * Usage:
10
+ * const html = beautifyTranscriptToEmailHTML(transcriptText, { title: "Cursor Agent Result" });
11
+ */
12
+
13
+ const css = `
14
+ :root{color-scheme:light}
15
+ body{margin:0;padding:0;background:#ffffff;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Arial}
16
+ .wrap{max-width:900px;margin:0 auto;padding:24px}
17
+ .card{border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;background:#fff}
18
+ .card + .card{margin-top:18px}
19
+ .hdr{padding:16px 18px;border-bottom:1px solid #e5e7eb;background:#f9fafb}
20
+ .hdr h1{margin:0;font-size:16px;line-height:1.3;font-weight:700;color:#111827}
21
+ .hdr .meta{margin-top:6px;font-size:12px;color:#6b7280}
22
+ .turn{padding:16px 18px;border-top:1px solid #f3f4f6}
23
+ .turn:first-child{border-top:none}
24
+ .role{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;padding:4px 8px;border-radius:999px}
25
+ .role.user{background:#eff6ff;color:#1d4ed8}
26
+ .role.assistant{background:#ecfdf5;color:#047857}
27
+ .content{margin-top:10px;font-size:13px;line-height:1.55;color:#111827}
28
+ .p{margin:10px 0}
29
+ .muted{color:#6b7280}
30
+ .box{border:1px solid #e5e7eb;border-radius:10px;background:#fafafa;padding:12px;margin:12px 0}
31
+ .qtitle{font-size:12px;font-weight:700;color:#111827;margin:0 0 8px 0}
32
+ .codehdr{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:8px}
33
+ .badge{font-size:11px;background:#f3f4f6;color:#111827;border:1px solid #e5e7eb;border-radius:999px;padding:3px 8px}
34
+ pre{margin:0;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.5;color:#0f172a}
35
+ .lines{counter-reset:ln}
36
+ .ln{display:block}
37
+ .ln:before{counter-increment:ln;content:counter(ln);display:inline-block;width:2.7em;margin-right:12px;text-align:right;color:#94a3b8}
38
+ .raw{margin-top:18px;border-top:1px dashed #e5e7eb;padding-top:14px}
39
+ .raw h2{margin:0 0 8px 0;font-size:12px;color:#6b7280;font-weight:700}
40
+ `.trim();
41
+
42
+ const esc = (s) =>
43
+ String(s)
44
+ .replaceAll("&", "&amp;")
45
+ .replaceAll("<", "&lt;")
46
+ .replaceAll(">", "&gt;")
47
+ .replaceAll('"', "&quot;")
48
+ .replaceAll("'", "&#39;");
49
+
50
+ const safeJson = (obj) => {
51
+ try {
52
+ return JSON.stringify(obj, null, 2);
53
+ } catch {
54
+ return String(obj);
55
+ }
56
+ };
57
+
58
+ const normalizeNewlines = (text) => String(text ?? "").replace(/\r\n/g, "\n");
59
+
60
+ const splitTurns = (src) => {
61
+ const turns = [];
62
+ const re = /^(user|assistant):[ \t]*$/gim;
63
+ let lastIndex = 0;
64
+ let m;
65
+ let currentRole = null;
66
+
67
+ while ((m = re.exec(src)) !== null) {
68
+ const role = m[1].toLowerCase();
69
+ const markerStart = m.index;
70
+ if (currentRole !== null) {
71
+ const chunk = src.slice(lastIndex, markerStart).trim();
72
+ turns.push({ role: currentRole, text: chunk });
73
+ }
74
+ currentRole = role;
75
+ lastIndex = re.lastIndex;
76
+ }
77
+ if (currentRole !== null) {
78
+ turns.push({ role: currentRole, text: src.slice(lastIndex).trim() });
79
+ } else {
80
+ turns.push({ role: "assistant", text: src.trim() });
81
+ }
82
+
83
+ return turns;
84
+ };
85
+
86
+ const renderInlineBlocks = (text) => {
87
+ let t = String(text ?? "");
88
+
89
+ // Replace <attached_files> placeholder
90
+ t = t.replace(/<attached_files>\s*<\/attached_files>|<attached_files>\s*/gi, "\n[attached_files]\n");
91
+
92
+ // Extract <user_query> blocks
93
+ const uq = [];
94
+ t = t.replace(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/gi, (_, inner) => {
95
+ uq.push(inner.trim());
96
+ return "\n{{{__USER_QUERY__}}}\n";
97
+ });
98
+
99
+ // Extract <code_selection ...> blocks
100
+ const codeSelections = [];
101
+ t = t.replace(
102
+ /<code_selection\b([^>]*)>\s*([\s\S]*?)\s*<\/code_selection>/gi,
103
+ (_, attrs, inner) => {
104
+ const pathMatch = attrs.match(/\bpath="([^"]+)"/i);
105
+ const linesMatch = attrs.match(/\blines="([^"]+)"/i);
106
+ codeSelections.push({
107
+ path: pathMatch ? pathMatch[1] : "",
108
+ lines: linesMatch ? linesMatch[1] : "",
109
+ code: inner.replace(/\s+$/g, ""),
110
+ });
111
+ return "\n{{{__CODE_SELECTION__}}}\n";
112
+ }
113
+ );
114
+
115
+ // Escape everything, then re-inject safe HTML blocks.
116
+ const parts = esc(t).split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
117
+
118
+ const blocks = [];
119
+ let uqIdx = 0;
120
+ let csIdx = 0;
121
+
122
+ for (const p of parts) {
123
+ if (p.includes("{{{__USER_QUERY__}}}")) {
124
+ const q = uq[uqIdx++] ?? "";
125
+ blocks.push(`
126
+ <div class="box">
127
+ <div class="qtitle">User query</div>
128
+ <div class="content"><pre>${esc(q)}</pre></div>
129
+ </div>
130
+ `.trim());
131
+ continue;
132
+ }
133
+ if (p.includes("{{{__CODE_SELECTION__}}}")) {
134
+ const cs = codeSelections[csIdx++] ?? { path: "", lines: "", code: "" };
135
+
136
+ // Parse line-numbered code like "100| ..."
137
+ const rawLines = String(cs.code ?? "").split("\n");
138
+ const normalized = rawLines
139
+ .map((ln) => ln.replace(/^\s*\d+\|\s?/, ""))
140
+ .join("\n")
141
+ .trimEnd();
142
+
143
+ blocks.push(`
144
+ <div class="box">
145
+ <div class="codehdr">
146
+ ${cs.path ? `<span class="badge">${esc(cs.path)}</span>` : ""}
147
+ ${cs.lines ? `<span class="badge">lines ${esc(cs.lines)}</span>` : ""}
148
+ </div>
149
+ <pre class="lines">${esc(normalized)
150
+ .split("\n")
151
+ .map((line) => `<span class="ln">${line || " "}</span>`)
152
+ .join("\n")}</pre>
153
+ </div>
154
+ `.trim());
155
+ continue;
156
+ }
157
+
158
+ blocks.push(`<div class="p"><pre>${p}</pre></div>`);
159
+ }
160
+
161
+ return blocks.join("\n");
162
+ };
163
+
164
+ const renderTurns = (transcript) => {
165
+ const src = normalizeNewlines(transcript);
166
+ const turns = splitTurns(src);
167
+
168
+ return turns
169
+ .filter((t) => t.text && t.text.length)
170
+ .map((t) => {
171
+ const roleClass = t.role === "user" ? "user" : "assistant";
172
+ return `
173
+ <div class="turn">
174
+ <span class="role ${roleClass}">${esc(t.role)}</span>
175
+ <div class="content">
176
+ ${renderInlineBlocks(t.text)}
177
+ </div>
178
+ </div>
179
+ `.trim();
180
+ })
181
+ .join("\n");
182
+ };
183
+
184
+ const renderCard = ({ title, metaLines = [], bodyHtml }) => {
185
+ const meta = metaLines
186
+ .filter(Boolean)
187
+ .map((line) => `<div class="meta">${esc(line)}</div>`)
188
+ .join("");
189
+
190
+ return `
191
+ <div class="card">
192
+ <div class="hdr">
193
+ <h1>${esc(title)}</h1>
194
+ ${meta}
195
+ </div>
196
+ ${bodyHtml}
197
+ </div>
198
+ `.trim();
199
+ };
200
+
201
+ const renderDocument = ({ cardsHtml, rawFallback = "" }) => `
202
+ <!doctype html>
203
+ <html>
204
+ <head>
205
+ <meta charset="utf-8" />
206
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
207
+ <style>${css}</style>
208
+ </head>
209
+ <body>
210
+ <div class="wrap">
211
+ ${cardsHtml}
212
+ ${rawFallback}
213
+ </div>
214
+ </body>
215
+ </html>
216
+ `.trim();
217
+
218
+ export function extractLatestResponseFromPayload(payload, rawFallback = "") {
219
+ const candidates = [
220
+ payload?.text,
221
+ payload?.finalResponse,
222
+ payload?.response,
223
+ payload?.message,
224
+ payload?.summary,
225
+ payload?.output,
226
+ ];
227
+
228
+ for (const c of candidates) {
229
+ if (!c) continue;
230
+ if (typeof c === "string") return c;
231
+ return safeJson(c);
232
+ }
233
+
234
+ return rawFallback || "";
235
+ }
236
+
237
+ export function getTranscriptPathFromPayload(payload) {
238
+ return payload?.transcript_path || payload?.transcriptPath || "";
239
+ }
240
+
241
+ export function beautifyTranscriptToEmailHTML(
242
+ transcript,
243
+ {
244
+ title = "Agent transcript",
245
+ includeRawFallback = true,
246
+ maxRawFallbackChars = 15000,
247
+ } = {}
248
+ ) {
249
+ const src = normalizeNewlines(transcript);
250
+ const renderedTurns = renderTurns(src);
251
+
252
+ const card = renderCard({
253
+ title,
254
+ metaLines: [new Date().toISOString()],
255
+ bodyHtml:
256
+ renderedTurns ||
257
+ `<div class="turn"><div class="content muted">No transcript content.</div></div>`,
258
+ });
259
+
260
+ const rawFallback =
261
+ includeRawFallback && src
262
+ ? `
263
+ <div class="raw">
264
+ <h2>Raw transcript (truncated)</h2>
265
+ <pre>${esc(src.slice(0, maxRawFallbackChars))}${
266
+ src.length > maxRawFallbackChars ? "\n...(truncated)" : ""
267
+ }</pre>
268
+ </div>
269
+ `.trim()
270
+ : "";
271
+
272
+ return renderDocument({ cardsHtml: card, rawFallback });
273
+ }
274
+
275
+ export function beautifyPayloadToEmailHTML(
276
+ payload,
277
+ {
278
+ title = "Cursor agent finished",
279
+ includeRawFallback = true,
280
+ maxRawFallbackChars = 15000,
281
+ transcriptText = "",
282
+ latestResponse,
283
+ transcriptPath,
284
+ } = {}
285
+ ) {
286
+ const responseText = latestResponse ?? extractLatestResponseFromPayload(payload);
287
+ const fullTranscript = transcriptText ?? payload?.transcript ?? "";
288
+ const path = transcriptPath ?? getTranscriptPathFromPayload(payload);
289
+ const now = new Date().toISOString();
290
+
291
+ const responseTurns = renderTurns(`assistant:\n${responseText || ""}`);
292
+ const responseCard = renderCard({
293
+ title: "Latest response",
294
+ metaLines: [now],
295
+ bodyHtml:
296
+ responseTurns ||
297
+ `<div class="turn"><div class="content muted">No latest response.</div></div>`,
298
+ });
299
+
300
+ const transcriptTurns = renderTurns(fullTranscript);
301
+ const transcriptCard = renderCard({
302
+ title: title || "Full transcript",
303
+ metaLines: [now, path ? `Transcript path: ${path}` : "Transcript path: (missing)"],
304
+ bodyHtml:
305
+ transcriptTurns ||
306
+ `<div class="turn"><div class="content muted">No transcript content.</div></div>`,
307
+ });
308
+
309
+ const rawFallback =
310
+ includeRawFallback && fullTranscript
311
+ ? `
312
+ <div class="raw">
313
+ <h2>Raw transcript (truncated)</h2>
314
+ <pre>${esc(fullTranscript.slice(0, maxRawFallbackChars))}${
315
+ fullTranscript.length > maxRawFallbackChars ? "\n...(truncated)" : ""
316
+ }</pre>
317
+ </div>
318
+ `.trim()
319
+ : "";
320
+
321
+ return renderDocument({ cardsHtml: [responseCard, transcriptCard].join("\n"), rawFallback });
322
+ }
@@ -0,0 +1,110 @@
1
+ import "dotenv/config";
2
+ import { Resend } from "resend";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import {
6
+ beautifyPayloadToEmailHTML,
7
+ extractLatestResponseFromPayload,
8
+ getTranscriptPathFromPayload,
9
+ } from "./beautify-transcript-email-html.js";
10
+
11
+ const LOG_FILE = process.env.CURSOR_HOOK_LOG_FILE || ".cursor/hooks/hook.log";
12
+ const DEBUG = process.env.CURSOR_HOOK_DEBUG === "1";
13
+
14
+ function log(line) {
15
+ const ts = new Date().toISOString();
16
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
17
+ fs.appendFileSync(LOG_FILE, `[${ts}] ${line}\n`, "utf8");
18
+ }
19
+
20
+ function readStdin() {
21
+ return new Promise((resolve) => {
22
+ let data = "";
23
+ process.stdin.setEncoding("utf8");
24
+ process.stdin.on("data", (chunk) => (data += chunk));
25
+ process.stdin.on("end", () => resolve(data));
26
+ });
27
+ }
28
+
29
+ function safeJson(obj) {
30
+ try { return JSON.stringify(obj, null, 2); } catch { return String(obj); }
31
+ }
32
+
33
+ function readTranscriptFile(transcriptPath) {
34
+ if (!transcriptPath) return "";
35
+ try {
36
+ return fs.readFileSync(transcriptPath, "utf8");
37
+ } catch (e) {
38
+ log(`Transcript read error: ${e?.message || e}`);
39
+ return "";
40
+ }
41
+ }
42
+
43
+ async function main() {
44
+ log("Hook start");
45
+
46
+ const enabled = process.env.CURSOR_EMAIL_ON_FINISH === "1";
47
+ if (!enabled) {
48
+ log("CURSOR_EMAIL_ON_FINISH!=1 -> exit");
49
+ process.exit(0);
50
+ }
51
+
52
+ const raw = await readStdin();
53
+ log(`stdin bytes=${Buffer.byteLength(raw || "", "utf8")}`);
54
+
55
+ let payload = {};
56
+ try {
57
+ payload = raw ? JSON.parse(raw) : {};
58
+ } catch (e) {
59
+ log(`JSON parse error: ${e?.message || e}`);
60
+ if (DEBUG) log(`RAW:\n${raw}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ if (DEBUG) log(`PAYLOAD:\n${safeJson(payload)}`);
65
+
66
+ const to = process.env.CURSOR_EMAIL_TO;
67
+ const from = process.env.RESEND_FROM;
68
+ const replyTo = process.env.RESEND_REPLY_TO;
69
+ const apiKey = process.env.RESEND_API_KEY;
70
+
71
+ if (!to || !from || !apiKey) {
72
+ log("Missing env vars: CURSOR_EMAIL_TO / RESEND_FROM / RESEND_API_KEY");
73
+ process.exit(1);
74
+ }
75
+
76
+ const baseSubject = process.env.CURSOR_EMAIL_SUBJECT ?? "Cursor agent finished";
77
+ const conversationId = payload?.conversation_id || payload?.conversationId || "";
78
+ const subject = conversationId
79
+ ? `${baseSubject} [${conversationId}]`
80
+ : baseSubject;
81
+ const text = extractLatestResponseFromPayload(payload, raw || "(empty hook payload)");
82
+ const transcriptPath = getTranscriptPathFromPayload(payload);
83
+ const transcriptText = readTranscriptFile(transcriptPath);
84
+ const html = beautifyPayloadToEmailHTML(payload, {
85
+ title: "Full transcript",
86
+ transcriptText,
87
+ latestResponse: text,
88
+ transcriptPath,
89
+ });
90
+
91
+ if (process.env.CURSOR_DRY_RUN === "1") {
92
+ log(`DRY_RUN=1 subject="${subject}" to="${to}" from="${from}" body_len=${text.length} html_len=${html.length}`);
93
+ process.exit(0);
94
+ }
95
+
96
+ const resend = new Resend(apiKey);
97
+ const result = await resend.emails.send({ from, to, subject, text, html, replyTo });
98
+
99
+ if (result?.error) {
100
+ log(`Resend error: ${safeJson(result.error)}`);
101
+ process.exit(1);
102
+ }
103
+
104
+ log(`Sent ok id=${result?.data?.id || "unknown"}`);
105
+ }
106
+
107
+ main().catch((e) => {
108
+ log(`Unhandled: ${e?.stack || e}`);
109
+ process.exit(1);
110
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "afterAgentResponse": [
5
+ {
6
+ "command": "npm run -s cursor:email-hook"
7
+ }
8
+ ]
9
+ }
10
+ }
@@ -0,0 +1,150 @@
1
+ import "dotenv/config";
2
+ import express from "express";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { Resend } from "resend";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const app = express();
9
+ app.use(express.json({ limit: "2mb" }));
10
+
11
+ // Map job ID to repository path (customize as needed)
12
+ function jobIdToRepoPath(jobId) {
13
+ // Default to current workspace; extend with a lookup table if needed
14
+ const mapping = {
15
+ // "job123": "/path/to/repo",
16
+ };
17
+ return mapping[jobId] || process.env.DEFAULT_REPO_PATH || process.cwd();
18
+ }
19
+
20
+ // Extract email address from "Name <email>" format
21
+ function extractEmail(from) {
22
+ const match = from.match(/<([^>]+)>/);
23
+ return match ? match[1] : from;
24
+ }
25
+
26
+ // Render CLI output as HTML
27
+ function renderResultHtml(stdout) {
28
+ const escaped = stdout
29
+ .replace(/&/g, "&amp;")
30
+ .replace(/</g, "&lt;")
31
+ .replace(/>/g, "&gt;");
32
+ return `<pre style="font-family:monospace;white-space:pre-wrap;">${escaped}</pre>`;
33
+ }
34
+
35
+ function stripQuotedReply(text = "") {
36
+ // Remove quoted lines and email citation headers (English and Spanish)
37
+ return text
38
+ .split("\n")
39
+ .filter((l) => !l.trim().startsWith(">"))
40
+ .join("\n")
41
+ .split(/\n\nOn .*wrote:\n/i)[0] // English: "On ... wrote:"
42
+ .split(/\n\nEl [^,]+,.*escribió:\n/i)[0] // Spanish: "El mié, 4 feb 2026...escribió:"
43
+ .split(/\n\nEl [^,]+,/)[0] // Spanish fallback: cut at "El día,"
44
+ .trim();
45
+ }
46
+
47
+ app.post("/inbound/resend", async (req, res) => {
48
+ try {
49
+ const dryRun = req.query.dryRun === "true" || process.env.DRY_RUN === "true";
50
+
51
+ if (dryRun) {
52
+ console.log("Payload received:", JSON.stringify(req.body, null, 2));
53
+ }
54
+
55
+ // Resend inbound event payload includes the parsed email metadata.
56
+ const event = req.body;
57
+ const data = event?.data || {};
58
+ const emailId = data?.email_id;
59
+
60
+ // Fetch full email content via Resend Receiving API
61
+ const resendClient = new Resend(process.env.RESEND_API_KEY);
62
+
63
+ if (dryRun) {
64
+ console.log("Email ID from webhook:", emailId);
65
+ }
66
+
67
+ // Use receiving API for inbound emails
68
+ const fullEmail = await resendClient.emails.receiving.get(emailId);
69
+
70
+ if (dryRun) {
71
+ console.log("Full email received");
72
+ }
73
+
74
+ const subject = data?.subject || "";
75
+ const to = (data?.to && data.to[0]) || "";
76
+ const from = data?.from || "";
77
+
78
+ // correlation: extract job ID (UUID) from subject like "[dcbd565f-6540-4794-b5e9-6e514346faa4]"
79
+ const m = subject.match(/\[([a-f0-9-]{36})\]/i);
80
+ if (!m) return res.status(200).json({ ok: true, ignored: "no job id" });
81
+ const jobId = m[1];
82
+
83
+ // Use text from Receiving API response
84
+ const replyText = stripQuotedReply(fullEmail.data?.text || "");
85
+
86
+ const repoPath = jobIdToRepoPath(jobId);
87
+ const prompt = replyText;
88
+
89
+ const cursorArgs = ["agent", "--resume", jobId, "--print", "--output-format", "json", prompt];
90
+ const cursorCommand = {
91
+ command: "cursor",
92
+ args: cursorArgs,
93
+ cwd: repoPath,
94
+ full: `cursor agent --resume ${jobId} --print --output-format json ${JSON.stringify(prompt)}`,
95
+ };
96
+
97
+ // Always print the command
98
+ console.log("Cursor command:", cursorCommand.full);
99
+
100
+ if (dryRun) {
101
+ return res.json({
102
+ ok: true,
103
+ dryRun: true,
104
+ parsed: {
105
+ jobId,
106
+ from,
107
+ to,
108
+ subject,
109
+ replyText,
110
+ repoPath,
111
+ },
112
+ cursorCommand,
113
+ email: {
114
+ from: process.env.RESEND_FROM,
115
+ to: extractEmail(from),
116
+ subject,
117
+ },
118
+ });
119
+ }
120
+
121
+ // Run Cursor CLI
122
+ const { stdout } = await execFileAsync(
123
+ cursorCommand.command,
124
+ cursorCommand.args,
125
+ { cwd: cursorCommand.cwd, timeout: 10 * 60 * 1000 }
126
+ );
127
+
128
+ await resendClient.emails.send({
129
+ from: process.env.RESEND_FROM,
130
+ to: extractEmail(from),
131
+ subject,
132
+ html: renderResultHtml(stdout),
133
+ });
134
+
135
+ res.json({ ok: true });
136
+ } catch (e) {
137
+ console.error("Webhook error:", e);
138
+ res.status(500).json({ ok: false, error: e.message });
139
+ }
140
+ });
141
+
142
+ // Health check endpoint
143
+ app.get("/health", (req, res) => {
144
+ res.json({ ok: true, timestamp: new Date().toISOString() });
145
+ });
146
+
147
+ const port = process.env.WEBHOOK_PORT || 8787;
148
+ app.listen(port, () => {
149
+ console.log(`Webhook server listening on port ${port}`);
150
+ });