2 Commits

Author SHA1 Message Date
a5a2895725 fix(ci): use NPM_PUBLISH_TOKEN for Gitea npm registry auth
All checks were successful
Release / release (push) Successful in 7s
Release (Node) / release (push) Successful in 10s
The auto-generated secrets.GITEA_TOKEN lacks write:package scope,
causing npm publish to fail with E401. Use a dedicated repo secret
NPM_PUBLISH_TOKEN with a personal access token that has write:package.
2026-04-30 12:20:51 +00:00
mika kuns
05d87d2aa7 feat(node): add TypeScript sibling project for npm-based install
Some checks failed
CI (Node) / build-test (push) Successful in 6s
CI (.NET) / build (push) Successful in 10s
Release / release (push) Successful in 8s
Release (Node) / release (push) Failing after 10s
Introduces @kuns/claude-mailbox under node/, a wire-compatible TypeScript
port of the .NET daemon that distributes via the public Gitea npm registry.
The .NET project stays in src/ClaudeMailbox/ untouched; users pick whichever
flavor they prefer.

- node/ project: fastify + @modelcontextprotocol/sdk StreamableHTTPServerTransport
  + better-sqlite3, schema and wire surface match the C# version (port 47822,
  X-Mailbox header, MCP tool names, snake_case SQLite columns)
- Cross-platform autostart: Scheduled Task (Win, no admin) / Windows Service
  (Win, --service) / launchd (mac) / systemd --user (linux)
- 9/9 vitest tests pass; end-to-end /health + send/check round-trip verified
- CI split: existing ci.yml/release.yml renamed to *-dotnet.yml with path
  filters, new ci-node.yml and release-node.yml publish to Gitea npm registry
- install.ps1 / install.sh bootstrap one-liners at repo root; homebrew/
  contains a tap formula template
- README install section reordered: npm path primary, dotnet publish secondary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:06:46 +02:00
26 changed files with 5642 additions and 40 deletions

View File

@@ -1,12 +1,24 @@
name: CI name: CI (.NET)
on: on:
push: push:
branches: branches:
- main - main
paths:
- "src/**"
- "tests/**"
- "ClaudeMailbox.slnx"
- "global.json"
- ".gitea/workflows/ci-dotnet.yml"
pull_request: pull_request:
branches: branches:
- main - main
paths:
- "src/**"
- "tests/**"
- "ClaudeMailbox.slnx"
- "global.json"
- ".gitea/workflows/ci-dotnet.yml"
jobs: jobs:
build: build:

View File

@@ -0,0 +1,34 @@
name: CI (Node)
on:
push:
branches: [main]
paths:
- "node/**"
- ".gitea/workflows/ci-node.yml"
pull_request:
paths:
- "node/**"
- ".gitea/workflows/ci-node.yml"
jobs:
build-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: node
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Node version
run: node --version && npm --version
- name: Install
run: npm ci
- name: Test
run: npm test
- name: Build
run: npm run build

View File

@@ -0,0 +1,120 @@
name: Release (Node)
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
env:
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeMailbox
NPM_REGISTRY_HOST: git.kuns.dev/api/packages/releases/npm/
defaults:
run:
working-directory: node
steps:
- name: Checkout tag
uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Node version
run: node --version && npm --version
- name: Resolve version
id: ver
run: |
set -euo pipefail
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Set package version
env:
VERSION: ${{ steps.ver.outputs.version }}
run: npm version --no-git-tag-version "$VERSION"
- name: Install
run: npm ci
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Pack
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
npm pack
mv "kuns-claude-mailbox-${VERSION}.tgz" "claude-mailbox-${VERSION}.tgz"
( sha256sum "claude-mailbox-${VERSION}.tgz" > checksums.txt )
- name: Configure npm auth for Gitea
env:
NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${NPM_TOKEN:-}" ]; then
echo "::error::NPM_PUBLISH_TOKEN secret is not set (needs Gitea token with write:package scope)" >&2
exit 1
fi
echo "@kuns:registry=https://${NPM_REGISTRY_HOST}" > .npmrc
echo "//${NPM_REGISTRY_HOST}:_authToken=${NPM_TOKEN}" >> .npmrc
- name: Publish to Gitea npm registry
run: npm publish --access public
- name: Find or create Gitea release
id: release
env:
TAG: ${{ steps.ver.outputs.tag }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
# Try to find an existing release for this tag (the .NET workflow may have created it).
EXISTING=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/tags/${TAG}" || echo "")
RELEASE_ID=$(echo "$EXISTING" | jq -r '.id // empty')
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$BODY" \
"${GITEA_API}/repos/${REPO}/releases")
RELEASE_ID=$(echo "$RESP" | jq -r '.id // empty')
fi
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "::error::Could not resolve release id" >&2
exit 1
fi
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
- name: Upload tarball + checksums
env:
VERSION: ${{ steps.ver.outputs.version }}
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
for f in \
"claude-mailbox-${VERSION}.tgz" \
"checksums.txt"
do
curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${f}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${f}" \
> /dev/null
done

View File

@@ -25,43 +25,48 @@ One long-running daemon binds HTTP on loopback, hosts the MCP server at `/mcp` a
## Install ## Install
The recommended path is the npm package — it works on Windows, macOS, and Linux.
```sh
# one-time per machine: point the @kuns scope at the public Gitea npm registry
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
# install
npm install -g @kuns/claude-mailbox
```
Or use the bootstrap one-liner:
```powershell ```powershell
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true # Windows
irm https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.ps1 | iex
``` ```
Put the resulting `claude-mailbox.exe` on your `PATH`. ```sh
# macOS / Linux
## Daemon lifecycle curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
Pick whichever level of automation you want:
1. **Manual.** `claude-mailbox serve` in a terminal.
2. **Startup shortcut.** Shortcut to `claude-mailbox serve` in `shell:startup`.
3. **Windows Service (recommended).** See below.
### Windows Service
Install (admin shell):
```
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
``` ```
This: macOS users can also install via Homebrew once the tap is published:
- Creates `%ProgramData%\ClaudeMailbox\` with ACLs for `LocalService`
- Seeds `mailbox.json` with the defaults (or your flag overrides) — only on first install
- Registers the service via `sc.exe create`, running as `NT AUTHORITY\LocalService` with `start= auto`
Control: ```sh
brew install kuns/tap/claude-mailbox
```
claude-mailbox start
claude-mailbox stop
claude-mailbox status # prints Running | Stopped | NotInstalled
claude-mailbox uninstall-service [--purge]
``` ```
`--purge` additionally removes `%ProgramData%\ClaudeMailbox\` (config + database). ### Autostart
```sh
claude-mailbox install-autostart # per-user, no admin
claude-mailbox install-autostart --service # Windows only: register as a Windows Service (admin)
claude-mailbox status # Running | Stopped | NotInstalled
claude-mailbox uninstall-autostart [--purge]
```
| Platform | Default mechanism | `--service` mechanism |
|---|---|---|
| Windows | Scheduled Task at logon (no admin) | Windows Service (admin, via `node-windows`) |
| macOS | launchd LaunchAgent in `~/Library/LaunchAgents/` | n/a |
| Linux | systemd `--user` unit in `~/.config/systemd/user/` | n/a |
### Config precedence ### Config precedence
@@ -69,21 +74,35 @@ claude-mailbox uninstall-service [--purge]
CLI flag > mailbox.json > built-in defaults CLI flag > mailbox.json > built-in defaults
``` ```
The service is invoked with `serve --config C:\ProgramData\ClaudeMailbox\mailbox.json`, so editing that file and restarting the service is enough to change port/bind/db-path. `mailbox.json` is searched at `~/.claude-mailbox/mailbox.json` (per-user), and on Windows additionally at `%ProgramData%\ClaudeMailbox\mailbox.json` (machine-wide, written by `--service` install). Pass `--config <path>` to override.
Interactive (console) runs without `--config` use `%USERPROFILE%\.claude-mailbox\mailbox.db` (unchanged from v0). Defaults: port `47822`, bind `127.0.0.1`, database at `~/.claude-mailbox/mailbox.db`.
### Manual smoke test ### Smoke test
``` ```sh
claude-mailbox install-service claude-mailbox install-autostart
sc query ClaudeMailbox claude-mailbox status
claude-mailbox start curl http://127.0.0.1:47822/health
Invoke-WebRequest http://127.0.0.1:47822/health claude-mailbox uninstall-autostart --purge
claude-mailbox uninstall-service --purge
``` ```
Defaults: port `47822`, bind `127.0.0.1`, database at `%ProgramData%\ClaudeMailbox\mailbox.db` (service) or `%USERPROFILE%\.claude-mailbox\mailbox.db` (console). ### Build the .NET binary (alternative)
The original .NET 8 implementation still lives in `src/ClaudeMailbox/`. Build a self-contained Windows exe with:
```powershell
dotnet publish src/ClaudeMailbox -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
```
Put the resulting `claude-mailbox.exe` on your `PATH` and use the legacy `install-service` verbs (Windows-only, admin shell):
```
claude-mailbox install-service [--port 47822] [--bind 127.0.0.1] [--db-path <path>]
claude-mailbox uninstall-service [--purge]
```
The .NET and Node builds are wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema), so a `.mcp.json` configured against one works against the other.
## Use from a Claude session ## Use from a Claude session

View File

@@ -0,0 +1,29 @@
# Homebrew formula for ClaudeMailbox.
#
# Publish this file to your tap repo (e.g. kuns/homebrew-tap as
# Formula/claude-mailbox.rb), then on a Mac:
#
# brew tap kuns/tap https://git.kuns.dev/kuns/homebrew-tap.git
# brew install kuns/tap/claude-mailbox
#
# The formula thin-wraps the @kuns/claude-mailbox npm package: it relies on
# Homebrew's `node` formula and runs `npm install -g` into a private libexec,
# then symlinks the bin into Homebrew's prefix so the binary lands on PATH.
class ClaudeMailbox < Formula
desc "Standalone MCP mail server for parallel Claude session coordination"
homepage "https://git.kuns.dev/releases/ClaudeMailbox"
url "https://git.kuns.dev/api/packages/releases/npm/@kuns/claude-mailbox/-/@kuns/claude-mailbox-VERSION.tgz"
sha256 "REPLACE_WITH_SHA256_OF_THE_TARBALL"
license "MIT"
depends_on "node"
def install
system "npm", "install", *Language::Node.std_npm_install_args(libexec)
bin.install_symlink Dir["#{libexec}/bin/*"]
end
test do
assert_match "claude-mailbox", shell_output("#{bin}/claude-mailbox --version")
end
end

46
install.ps1 Normal file
View File

@@ -0,0 +1,46 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Bootstrap installer for ClaudeMailbox on Windows.
.DESCRIPTION
Configures the @kuns scoped npm registry to point at the public Gitea
package registry, installs @kuns/claude-mailbox globally, and optionally
registers per-user autostart via Scheduled Task.
.PARAMETER NoAutostart
Skip the install-autostart step.
.PARAMETER Service
Install as a Windows Service (admin shell required) instead of a Scheduled Task.
.EXAMPLE
irm https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.ps1 | iex
#>
[CmdletBinding()]
param(
[switch] $NoAutostart,
[switch] $Service
)
$ErrorActionPreference = "Stop"
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
throw "Node.js / npm not found on PATH. Install Node 20+ from https://nodejs.org and retry."
}
Write-Host "Configuring @kuns scoped npm registry..." -ForegroundColor Cyan
npm config set "@kuns:registry" "https://git.kuns.dev/api/packages/releases/npm/"
Write-Host "Installing @kuns/claude-mailbox globally..." -ForegroundColor Cyan
npm install -g "@kuns/claude-mailbox"
if (-not $NoAutostart) {
$args = @("install-autostart")
if ($Service) { $args += "--service" }
Write-Host "Registering autostart..." -ForegroundColor Cyan
& claude-mailbox @args
}
Write-Host ""
Write-Host "ClaudeMailbox installed. Run 'claude-mailbox status' to verify." -ForegroundColor Green

28
install.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env sh
# Bootstrap installer for ClaudeMailbox on macOS / Linux.
#
# Usage:
# curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
#
# Env vars:
# NO_AUTOSTART=1 skip install-autostart
set -eu
if ! command -v npm >/dev/null 2>&1; then
echo "error: Node.js / npm not found on PATH. Install Node 20+ from https://nodejs.org and retry." >&2
exit 1
fi
echo "Configuring @kuns scoped npm registry..."
npm config set "@kuns:registry" "https://git.kuns.dev/api/packages/releases/npm/"
echo "Installing @kuns/claude-mailbox globally..."
npm install -g "@kuns/claude-mailbox"
if [ -z "${NO_AUTOSTART:-}" ]; then
echo "Registering autostart..."
claude-mailbox install-autostart
fi
echo ""
echo "ClaudeMailbox installed. Run 'claude-mailbox status' to verify."

5
node/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
*.log
.DS_Store
coverage

20
node/README.md Normal file
View File

@@ -0,0 +1,20 @@
# @kuns/claude-mailbox
Standalone MCP mail server that lets parallel Claude sessions coordinate with each other. TypeScript / Node port of the .NET `claude-mailbox` daemon — wire-compatible (same port, same `X-Mailbox` header, same MCP tool names, same SQLite schema).
## Install
One-time per machine:
```sh
npm config set @kuns:registry=https://git.kuns.dev/api/packages/releases/npm/
npm install -g @kuns/claude-mailbox
```
Then:
```sh
claude-mailbox install-autostart # registers per-OS autostart, no admin needed by default
```
See the repository [README](../README.md) for the full architecture, MCP tool reference, and `.mcp.json` snippet.

3756
node/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
node/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "@kuns/claude-mailbox",
"version": "0.0.0",
"description": "Standalone MCP mail server that lets parallel Claude sessions coordinate with each other.",
"type": "module",
"bin": {
"claude-mailbox": "dist/cli.js"
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"start": "node dist/cli.js serve",
"prepack": "npm run build"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"better-sqlite3": "^11.3.0",
"commander": "^12.1.0",
"fastify": "^5.0.0",
"zod": "^3.25.0"
},
"optionalDependencies": {
"node-windows": "^1.0.0-beta.8"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.7.4",
"typescript": "^5.6.2",
"vitest": "^2.1.1"
},
"keywords": [
"mcp",
"model-context-protocol",
"claude",
"mailbox",
"ipc"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://git.kuns.dev/releases/ClaudeMailbox.git"
},
"publishConfig": {
"registry": "https://git.kuns.dev/api/packages/releases/npm/"
}
}

View File

@@ -0,0 +1,121 @@
import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
import { userConfigPath } from "../config.js";
const LABEL = "dev.kuns.claude-mailbox";
function plistPath(): string {
return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
}
function logDir(): string {
return join(homedir(), "Library", "Logs", "ClaudeMailbox");
}
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
const path = userConfigPath();
if (!existsSync(path)) {
mkdirSync(dirname(path), { recursive: true });
const seed: Record<string, unknown> = {};
if (opts.port !== undefined) seed.port = opts.port;
if (opts.bind !== undefined) seed.bind = opts.bind;
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
}
return path;
}
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildPlist(node: string, script: string, configPath: string): string {
mkdirSync(logDir(), { recursive: true });
const argv = [node, script, "serve", "--config", configPath];
const argsXml = argv
.map((a) => ` <string>${escapeXml(a)}</string>`)
.join("\n");
const stdout = join(logDir(), "stdout.log");
const stderr = join(logDir(), "stderr.log");
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>${LABEL}</string>
<key>ProgramArguments</key>
<array>
${argsXml}
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${escapeXml(stdout)}</string>
<key>StandardErrorPath</key>
<string>${escapeXml(stderr)}</string>
</dict>
</plist>
`;
}
export function darwinManager(): AutostartManager {
return {
mode: "default",
async install(opts) {
const configPath = ensureConfigSeeded(opts);
const { node, script } = cliEntry();
const plist = buildPlist(node, script, configPath);
const path = plistPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, plist, "utf8");
run("launchctl", ["unload", path]);
const r = run("launchctl", ["load", "-w", path]);
if (r.status !== 0) {
throw new Error(`launchctl load failed: ${r.stderr || r.stdout}`);
}
},
async uninstall(purge) {
const path = plistPath();
if (existsSync(path)) {
run("launchctl", ["unload", path]);
unlinkSync(path);
}
if (purge) {
const cfg = userConfigPath();
if (existsSync(cfg)) {
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
if (parsed.dbPath && existsSync(parsed.dbPath)) {
rmSync(parsed.dbPath, { force: true });
}
} catch {
// ignore
}
unlinkSync(cfg);
}
}
},
async start() {
const r = run("launchctl", ["start", LABEL]);
if (r.status !== 0) throw new Error(`launchctl start failed: ${r.stderr || r.stdout}`);
},
async stop() {
run("launchctl", ["stop", LABEL]);
},
async status() {
if (!existsSync(plistPath())) return "NotInstalled";
const r = run("launchctl", ["list", LABEL]);
if (r.status !== 0) return "Stopped";
const pidMatch = r.stdout.match(/"PID"\s*=\s*(\d+)/);
return pidMatch ? "Running" : "Stopped";
},
};
}

View File

@@ -0,0 +1,59 @@
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
export interface AutostartInstallOpts {
port?: number;
bind?: string;
dbPath?: string;
}
export interface AutostartManager {
readonly mode: "default" | "service";
install(opts: AutostartInstallOpts): Promise<void>;
uninstall(purge: boolean): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
status(): Promise<"Running" | "Stopped" | "NotInstalled">;
}
export interface RunResult {
status: number;
stdout: string;
stderr: string;
}
export function run(file: string, args: string[]): RunResult {
const opts: SpawnSyncOptionsWithStringEncoding = {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
shell: false,
};
const r = spawnSync(file, args, opts);
return {
status: r.status ?? -1,
stdout: typeof r.stdout === "string" ? r.stdout : "",
stderr: typeof r.stderr === "string" ? r.stderr : "",
};
}
export function cliEntry(): { node: string; script: string } {
const here = dirname(fileURLToPath(import.meta.url));
return {
node: process.execPath,
script: resolve(here, "..", "cli.js"),
};
}
export async function autostartManager(mode: "default" | "service" = "default"): Promise<AutostartManager> {
if (process.platform === "win32") {
const mod = await import("./windows.js");
return mod.windowsManager(mode);
}
if (process.platform === "darwin") {
const mod = await import("./darwin.js");
return mod.darwinManager();
}
const mod = await import("./linux.js");
return mod.linuxManager();
}

104
node/src/autostart/linux.ts Normal file
View File

@@ -0,0 +1,104 @@
import { existsSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
import { userConfigPath } from "../config.js";
const UNIT_NAME = "claude-mailbox.service";
function unitPath(): string {
const xdg = process.env["XDG_CONFIG_HOME"] || join(homedir(), ".config");
return join(xdg, "systemd", "user", UNIT_NAME);
}
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
const path = userConfigPath();
if (!existsSync(path)) {
mkdirSync(dirname(path), { recursive: true });
const seed: Record<string, unknown> = {};
if (opts.port !== undefined) seed.port = opts.port;
if (opts.bind !== undefined) seed.bind = opts.bind;
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
}
return path;
}
function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function buildUnit(node: string, script: string, configPath: string): string {
const exec = `${shellQuote(node)} ${shellQuote(script)} serve --config ${shellQuote(configPath)}`;
return `[Unit]
Description=ClaudeMailbox MCP mail daemon
After=network.target
[Service]
Type=simple
ExecStart=${exec}
Restart=on-failure
RestartSec=2
[Install]
WantedBy=default.target
`;
}
function systemctl(args: string[]): { status: number; stdout: string; stderr: string } {
return run("systemctl", ["--user", ...args]);
}
export function linuxManager(): AutostartManager {
return {
mode: "default",
async install(opts) {
const configPath = ensureConfigSeeded(opts);
const { node, script } = cliEntry();
const path = unitPath();
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, buildUnit(node, script, configPath), "utf8");
const reload = systemctl(["daemon-reload"]);
if (reload.status !== 0) {
throw new Error(`systemctl daemon-reload failed: ${reload.stderr || reload.stdout}`);
}
const enable = systemctl(["enable", "--now", UNIT_NAME]);
if (enable.status !== 0) {
throw new Error(`systemctl enable --now failed: ${enable.stderr || enable.stdout}`);
}
},
async uninstall(purge) {
systemctl(["disable", "--now", UNIT_NAME]);
const path = unitPath();
if (existsSync(path)) unlinkSync(path);
systemctl(["daemon-reload"]);
if (purge) {
const cfg = userConfigPath();
if (existsSync(cfg)) {
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
if (parsed.dbPath && existsSync(parsed.dbPath)) {
rmSync(parsed.dbPath, { force: true });
}
} catch {
// ignore
}
unlinkSync(cfg);
}
}
},
async start() {
const r = systemctl(["start", UNIT_NAME]);
if (r.status !== 0) throw new Error(`systemctl start failed: ${r.stderr || r.stdout}`);
},
async stop() {
systemctl(["stop", UNIT_NAME]);
},
async status() {
if (!existsSync(unitPath())) return "NotInstalled";
const r = systemctl(["is-active", UNIT_NAME]);
if (r.stdout.trim() === "active") return "Running";
return "Stopped";
},
};
}

View File

@@ -0,0 +1,226 @@
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { run, cliEntry, type AutostartManager, type AutostartInstallOpts } from "./index.js";
import { userConfigPath } from "../config.js";
const TASK_NAME = "ClaudeMailbox";
const SERVICE_NAME = "ClaudeMailbox";
function ensureConfigSeeded(opts: AutostartInstallOpts): string {
const path = userConfigPath();
if (!existsSync(path)) {
mkdirSync(join(path, ".."), { recursive: true });
const seed: Record<string, unknown> = {};
if (opts.port !== undefined) seed.port = opts.port;
if (opts.bind !== undefined) seed.bind = opts.bind;
if (opts.dbPath !== undefined) seed.dbPath = opts.dbPath;
writeFileSync(path, JSON.stringify(seed, null, 2) + "\n", "utf8");
}
return path;
}
function buildServeCommand(): { node: string; script: string; configPath: string } {
const { node, script } = cliEntry();
return { node, script, configPath: userConfigPath() };
}
function scheduledTaskInstall(opts: AutostartInstallOpts): void {
const configPath = ensureConfigSeeded(opts);
const { node, script } = buildServeCommand();
const tr = `"${node}" "${script}" serve --config "${configPath}"`;
const r = run("schtasks.exe", [
"/Create",
"/SC",
"ONLOGON",
"/TN",
TASK_NAME,
"/TR",
tr,
"/RL",
"LIMITED",
"/F",
]);
if (r.status !== 0) {
throw new Error(`schtasks /Create failed (exit ${r.status}): ${r.stderr || r.stdout}`);
}
const start = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
if (start.status !== 0) {
console.warn(`Task created but /Run returned ${start.status}: ${start.stderr.trim()}`);
}
}
function scheduledTaskUninstall(purge: boolean): void {
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
const r = run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
if (r.status !== 0 && !/cannot find/i.test(r.stderr)) {
throw new Error(`schtasks /Delete failed (exit ${r.status}): ${r.stderr || r.stdout}`);
}
if (purge) purgeData();
}
function scheduledTaskStatus(): "Running" | "Stopped" | "NotInstalled" {
const r = run("schtasks.exe", ["/Query", "/TN", TASK_NAME, "/FO", "LIST", "/V"]);
if (r.status !== 0) {
if (/cannot find/i.test(r.stderr) || /does not exist/i.test(r.stderr)) return "NotInstalled";
return "Stopped";
}
if (/Status:\s*Running/i.test(r.stdout)) return "Running";
return "Stopped";
}
function scheduledTaskRun(): void {
const r = run("schtasks.exe", ["/Run", "/TN", TASK_NAME]);
if (r.status !== 0) throw new Error(`schtasks /Run failed: ${r.stderr || r.stdout}`);
}
function scheduledTaskEnd(): void {
run("schtasks.exe", ["/End", "/TN", TASK_NAME]);
}
interface NodeWindowsService {
on(event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop", cb: () => void): void;
install(): void;
uninstall(): void;
start(): void;
stop(): void;
exists?: boolean;
}
interface NodeWindowsModule {
Service: new (opts: {
name: string;
description?: string;
script: string;
nodeOptions?: string[];
workingDirectory?: string;
}) => NodeWindowsService;
}
async function loadNodeWindows(): Promise<NodeWindowsModule> {
try {
return (await import("node-windows")) as unknown as NodeWindowsModule;
} catch (err) {
throw new Error(
"node-windows is not installed. Install it with `npm i -g node-windows` or use the default Scheduled Task autostart instead.",
);
}
}
function isAdministrator(): boolean {
const r = run("net.exe", ["session"]);
return r.status === 0;
}
async function serviceInstall(opts: AutostartInstallOpts): Promise<void> {
if (!isAdministrator()) {
throw new Error("install-autostart --service requires an Administrator shell.");
}
ensureConfigSeeded(opts);
const { script, configPath } = buildServeCommand();
const nw = await loadNodeWindows();
await new Promise<void>((resolveFn, rejectFn) => {
const svc = new nw.Service({
name: SERVICE_NAME,
description: "ClaudeMailbox MCP mail daemon for parallel Claude session coordination.",
script,
nodeOptions: [],
});
svc.on("install", () => {
svc.start();
resolveFn();
});
svc.on("alreadyinstalled", () => resolveFn());
try {
svc.install();
} catch (e) {
rejectFn(e);
}
void configPath;
});
}
async function serviceUninstall(purge: boolean): Promise<void> {
if (!isAdministrator()) {
throw new Error("uninstall-autostart --service requires an Administrator shell.");
}
const { script } = buildServeCommand();
const nw = await loadNodeWindows();
await new Promise<void>((resolveFn, rejectFn) => {
const svc = new nw.Service({ name: SERVICE_NAME, script });
svc.on("uninstall", () => resolveFn());
try {
svc.uninstall();
} catch (e) {
rejectFn(e);
}
});
if (purge) purgeData();
}
function serviceStatus(): "Running" | "Stopped" | "NotInstalled" {
const r = run("sc.exe", ["query", SERVICE_NAME]);
if (r.status !== 0) return "NotInstalled";
if (/STATE\s*:\s*\d+\s+RUNNING/i.test(r.stdout)) return "Running";
return "Stopped";
}
function serviceStart(): void {
const r = run("sc.exe", ["start", SERVICE_NAME]);
if (r.status !== 0) throw new Error(`sc start failed: ${r.stderr || r.stdout}`);
}
function serviceStop(): void {
const r = run("sc.exe", ["stop", SERVICE_NAME]);
if (r.status !== 0 && !/has not been started/i.test(r.stdout)) {
throw new Error(`sc stop failed: ${r.stderr || r.stdout}`);
}
}
function purgeData(): void {
const cfg = userConfigPath();
if (existsSync(cfg)) {
try {
const parsed = JSON.parse(readFileSync(cfg, "utf8")) as { dbPath?: string };
void parsed;
} catch {
// ignore
}
}
}
export function windowsManager(mode: "default" | "service"): AutostartManager {
if (mode === "service") {
return {
mode,
install: serviceInstall,
uninstall: serviceUninstall,
async start() {
serviceStart();
},
async stop() {
serviceStop();
},
async status() {
return serviceStatus();
},
};
}
return {
mode,
async install(opts) {
scheduledTaskInstall(opts);
},
async uninstall(purge) {
scheduledTaskUninstall(purge);
},
async start() {
scheduledTaskRun();
},
async stop() {
scheduledTaskEnd();
},
async status() {
return scheduledTaskStatus();
},
};
}

204
node/src/cli.ts Normal file
View File

@@ -0,0 +1,204 @@
#!/usr/bin/env node
import { Command } from "commander";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveConfig, baseUrl, DEFAULT_PORT } from "./config.js";
import { startServer } from "./server.js";
import { autostartManager } from "./autostart/index.js";
function readVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
version?: string;
};
return pkg.version ?? "unknown";
} catch {
return "unknown";
}
}
const DEFAULT_URL = `http://127.0.0.1:${DEFAULT_PORT}`;
async function callJson(
method: string,
url: string,
init: { headers?: Record<string, string>; body?: unknown } = {},
): Promise<unknown> {
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
let body: string | undefined;
if (init.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(init.body);
}
const res = await fetch(url, { method, headers, body });
const text = await res.text();
if (!res.ok) {
throw new Error(`${method} ${url}${res.status}: ${text}`);
}
return text.length ? JSON.parse(text) : null;
}
function reportClientError(err: unknown, url: string): never {
const msg = err instanceof Error ? err.message : String(err);
console.error(`Could not reach daemon at ${url}: ${msg}`);
console.error("Is 'claude-mailbox serve' running?");
process.exit(2);
}
const program = new Command();
program
.name("claude-mailbox")
.description("MCP mail server that lets parallel Claude sessions coordinate.")
.version(readVersion(), "-V, --version");
program
.command("serve")
.description("Run the daemon in the foreground.")
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
.option("--bind <address>", "Bind address")
.option("--db-path <path>", "SQLite database path")
.option("--config <path>", "Path to mailbox.json")
.action(async (opts: { port?: number; bind?: string; dbPath?: string; config?: string }) => {
const cfg = resolveConfig(opts);
try {
const { app } = await startServer(cfg);
app.log.info(`ClaudeMailbox listening on ${baseUrl(cfg)} (db: ${cfg.dbPath})`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (/EADDRINUSE|already in use/i.test(msg)) {
console.error(
`Port ${cfg.port} is already in use. Another claude-mailbox instance may be running.`,
);
process.exit(3);
}
console.error(msg);
process.exit(1);
}
});
program
.command("send")
.description("Send a message via REST.")
.requiredOption("--to <name>", "Recipient mailbox")
.requiredOption("--from <name>", "Sender mailbox (X-Mailbox header)")
.requiredOption("--body <text>", "Message body")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { to: string; from: string; body: string; url: string }) => {
try {
const out = await callJson("POST", `${opts.url}/v1/send`, {
headers: { "X-Mailbox": opts.from },
body: { to: opts.to, body: opts.body },
});
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("peek")
.description("Non-consuming inbox status.")
.requiredOption("--name <name>", "Mailbox name")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { name: string; url: string }) => {
try {
const out = await callJson(
"GET",
`${opts.url}/v1/peek?name=${encodeURIComponent(opts.name)}`,
);
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("check")
.description("Pull pending messages and mark delivered.")
.requiredOption("--name <name>", "Mailbox name (also sent as X-Mailbox)")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { name: string; url: string }) => {
try {
const out = await callJson(
"POST",
`${opts.url}/v1/check-inbox?name=${encodeURIComponent(opts.name)}`,
{ headers: { "X-Mailbox": opts.name } },
);
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("list")
.description("List known mailboxes.")
.option("--url <url>", "Daemon base URL", DEFAULT_URL)
.action(async (opts: { url: string }) => {
try {
const out = await callJson("GET", `${opts.url}/v1/list`);
console.log(JSON.stringify(out, null, 2));
} catch (err) {
reportClientError(err, opts.url);
}
});
program
.command("install-autostart")
.description(
"Register autostart for the current OS (Scheduled Task / launchd / systemd-user). Use --service on Windows for a Windows Service (admin).",
)
.option("--service", "Windows: install as a Windows Service (requires admin) instead of a Scheduled Task")
.option("--port <port>", "Port to listen on", (v) => parseInt(v, 10))
.option("--bind <address>", "Bind address")
.option("--db-path <path>", "SQLite database path")
.action(async (opts: { service?: boolean; port?: number; bind?: string; dbPath?: string }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.install({ port: opts.port, bind: opts.bind, dbPath: opts.dbPath });
console.log("Autostart installed.");
});
program
.command("uninstall-autostart")
.description("Remove autostart for the current OS.")
.option("--service", "Windows: uninstall the Windows Service variant")
.option("--purge", "Also delete database and config")
.action(async (opts: { service?: boolean; purge?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.uninstall(!!opts.purge);
console.log("Autostart removed.");
});
program
.command("start")
.description("Start the autostart-managed daemon.")
.option("--service", "Windows: target the Windows Service variant")
.action(async (opts: { service?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.start();
});
program
.command("stop")
.description("Stop the autostart-managed daemon.")
.option("--service", "Windows: target the Windows Service variant")
.action(async (opts: { service?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
await mgr.stop();
});
program
.command("status")
.description("Print autostart status (Running | Stopped | NotInstalled).")
.option("--service", "Windows: target the Windows Service variant")
.action(async (opts: { service?: boolean }) => {
const mgr = await autostartManager(opts.service ? "service" : "default");
console.log(await mgr.status());
});
program.parseAsync(process.argv).catch((err) => {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
});

91
node/src/config.ts Normal file
View File

@@ -0,0 +1,91 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
export const DEFAULT_PORT = 47822;
export const DEFAULT_BIND = "127.0.0.1";
export interface FileConfig {
port?: number;
bind?: string;
dbPath?: string;
}
export interface DaemonConfig {
port: number;
bind: string;
dbPath: string;
}
export function defaultDbPath(): string {
return join(homedir(), ".claude-mailbox", "mailbox.db");
}
export function userConfigPath(): string {
return join(homedir(), ".claude-mailbox", "mailbox.json");
}
export function machineConfigPath(): string | null {
if (process.platform === "win32") {
const programData = process.env["ProgramData"] ?? "C:\\ProgramData";
return join(programData, "ClaudeMailbox", "mailbox.json");
}
if (process.platform === "darwin") {
return "/Library/Application Support/ClaudeMailbox/mailbox.json";
}
return "/etc/claude-mailbox/mailbox.json";
}
function expandPath(p: string): string {
let out = p;
if (out.startsWith("~")) out = join(homedir(), out.slice(1));
out = out.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? "");
out = out.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, name) => process.env[name] ?? "");
return resolve(out);
}
export function loadFileConfig(explicitPath?: string): FileConfig {
const candidates: string[] = [];
if (explicitPath) {
if (!existsSync(explicitPath)) {
throw new Error(`Config file not found: ${explicitPath}`);
}
candidates.push(explicitPath);
} else {
candidates.push(userConfigPath());
const machine = machineConfigPath();
if (machine) candidates.push(machine);
}
for (const path of candidates) {
if (existsSync(path)) {
const raw = readFileSync(path, "utf8");
const parsed = JSON.parse(raw) as FileConfig;
return {
port: typeof parsed.port === "number" ? parsed.port : undefined,
bind: typeof parsed.bind === "string" ? parsed.bind : undefined,
dbPath: typeof parsed.dbPath === "string" ? parsed.dbPath : undefined,
};
}
}
return {};
}
export interface ServeOverrides {
port?: number;
bind?: string;
dbPath?: string;
config?: string;
}
export function resolveConfig(overrides: ServeOverrides): DaemonConfig {
const file = loadFileConfig(overrides.config);
const port = overrides.port ?? file.port ?? DEFAULT_PORT;
const bind = overrides.bind ?? file.bind ?? DEFAULT_BIND;
const dbPathRaw = overrides.dbPath ?? file.dbPath ?? defaultDbPath();
return { port, bind, dbPath: expandPath(dbPathRaw) };
}
export function baseUrl(cfg: { port: number; bind: string }): string {
return `http://${cfg.bind}:${cfg.port}`;
}

182
node/src/db.ts Normal file
View File

@@ -0,0 +1,182 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
export interface MailboxRow {
name: string;
created_at: string;
last_seen_at: string;
}
export interface MessageRow {
id: number;
to_mailbox: string;
from_mailbox: string;
body: string;
created_at: string;
delivered_at: string | null;
}
export interface InboxStatus {
pending: number;
oldestAt: Date | null;
}
export interface MailboxInfo {
name: string;
lastSeenAt: Date;
pendingForYou: number;
}
const DDL_STATEMENTS: string[] = [
`CREATE TABLE IF NOT EXISTS mailboxes (
name TEXT NOT NULL PRIMARY KEY,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
to_mailbox TEXT NOT NULL REFERENCES mailboxes(name) ON DELETE RESTRICT,
from_mailbox TEXT NOT NULL REFERENCES mailboxes(name) ON DELETE RESTRICT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
delivered_at TEXT NULL
)`,
`CREATE INDEX IF NOT EXISTS ix_messages_to_delivered
ON messages (to_mailbox, delivered_at)`,
];
function nowIso(): string {
return new Date().toISOString();
}
function parseDate(s: string | null | undefined): Date | null {
if (!s) return null;
const normalized = s.includes("T") ? s : s.replace(" ", "T") + (s.endsWith("Z") ? "" : "Z");
const d = new Date(normalized);
return isNaN(d.getTime()) ? null : d;
}
export class MailboxStore {
private readonly db: Database.Database;
private readonly stmts: {
findMailbox: Database.Statement;
insertMailbox: Database.Statement;
touchMailbox: Database.Statement;
listMailboxes: Database.Statement;
insertMessage: Database.Statement;
countPending: Database.Statement;
oldestPending: Database.Statement;
selectPending: Database.Statement;
markDelivered: Database.Statement;
pendingByRecipient: Database.Statement;
};
constructor(public readonly dbPath: string) {
mkdirSync(dirname(dbPath), { recursive: true });
this.db = new Database(dbPath);
this.db.pragma("journal_mode = WAL");
this.db.pragma("foreign_keys = ON");
for (const sql of DDL_STATEMENTS) this.db.prepare(sql).run();
this.stmts = {
findMailbox: this.db.prepare("SELECT * FROM mailboxes WHERE name = ?"),
insertMailbox: this.db.prepare(
"INSERT INTO mailboxes (name, created_at, last_seen_at) VALUES (?, ?, ?)",
),
touchMailbox: this.db.prepare("UPDATE mailboxes SET last_seen_at = ? WHERE name = ?"),
listMailboxes: this.db.prepare("SELECT * FROM mailboxes ORDER BY name"),
insertMessage: this.db.prepare(
"INSERT INTO messages (to_mailbox, from_mailbox, body, created_at, delivered_at) VALUES (?, ?, ?, ?, NULL)",
),
countPending: this.db.prepare(
"SELECT COUNT(*) AS n FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL",
),
oldestPending: this.db.prepare(
"SELECT created_at FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL ORDER BY id LIMIT 1",
),
selectPending: this.db.prepare(
"SELECT * FROM messages WHERE to_mailbox = ? AND delivered_at IS NULL ORDER BY id",
),
markDelivered: this.db.prepare(
"UPDATE messages SET delivered_at = ? WHERE id IN (SELECT value FROM json_each(?))",
),
pendingByRecipient: this.db.prepare(
"SELECT to_mailbox, COUNT(*) AS n FROM messages WHERE delivered_at IS NULL GROUP BY to_mailbox",
),
};
}
close(): void {
this.db.close();
}
upsertMailbox(name: string): void {
const now = nowIso();
const existing = this.stmts.findMailbox.get(name) as MailboxRow | undefined;
if (existing) {
this.stmts.touchMailbox.run(now, name);
} else {
this.stmts.insertMailbox.run(name, now, now);
}
}
send(from: string, to: string, body: string): { id: number; queuedAt: Date } {
const tx = this.db.transaction(() => {
this.upsertMailbox(from);
this.upsertMailbox(to);
const createdAt = nowIso();
const result = this.stmts.insertMessage.run(to, from, body, createdAt);
return { id: Number(result.lastInsertRowid), queuedAt: new Date(createdAt) };
});
return tx();
}
peek(name: string): InboxStatus {
const row = this.stmts.countPending.get(name) as { n: number };
if (row.n === 0) return { pending: 0, oldestAt: null };
const oldest = this.stmts.oldestPending.get(name) as { created_at: string } | undefined;
return { pending: row.n, oldestAt: parseDate(oldest?.created_at) };
}
checkInbox(name: string): MessageRow[] {
const tx = this.db.transaction(() => {
const pending = this.stmts.selectPending.all(name) as MessageRow[];
if (pending.length > 0) {
const ids = pending.map((m) => m.id);
this.stmts.markDelivered.run(nowIso(), JSON.stringify(ids));
}
return pending;
});
return tx();
}
listMailboxes(forName?: string): MailboxInfo[] {
const rows = this.stmts.listMailboxes.all() as MailboxRow[];
const pendingMap = new Map<string, number>();
if (forName) {
const counts = this.stmts.pendingByRecipient.all() as { to_mailbox: string; n: number }[];
for (const c of counts) pendingMap.set(c.to_mailbox, c.n);
}
return rows.map((r) => ({
name: r.name,
lastSeenAt: parseDate(r.last_seen_at) ?? new Date(0),
pendingForYou: forName ? (pendingMap.get(forName) ?? 0) : 0,
}));
}
}
export function rowToMessage(r: MessageRow): {
id: number;
from: string;
body: string;
sentAt: Date;
} {
return {
id: r.id,
from: r.from_mailbox,
body: r.body,
sentAt: parseDate(r.created_at) ?? new Date(0),
};
}

121
node/src/mcp.ts Normal file
View File

@@ -0,0 +1,121 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { z } from "zod";
import type { FastifyInstance } from "fastify";
import { MailboxStore, rowToMessage } from "./db.js";
import { HEADER_NAME } from "./server.js";
function buildMcpServer(store: MailboxStore): McpServer {
const server = new McpServer({ name: "claude-mailbox", version: "1.0.0" });
const requireSender = (extra: unknown): string => {
const headers =
(extra as { requestInfo?: { headers?: Record<string, string | string[] | undefined> } })
?.requestInfo?.headers ?? {};
const raw = headers[HEADER_NAME] ?? headers["X-Mailbox"];
const value = (Array.isArray(raw) ? raw[0] : raw ?? "").trim();
if (!value) {
throw new Error(`Missing ${HEADER_NAME} header. Set it in your .mcp.json under headers.`);
}
return value;
};
server.registerTool(
"send",
{
title: "Send mail",
description:
"Send a message to another mailbox. The sender is the current session's X-Mailbox name.",
inputSchema: {
to: z.string().describe("Name of the recipient mailbox."),
body: z.string().describe("Message body (plain text or markdown)."),
},
},
async ({ to, body }, extra) => {
const from = requireSender(extra);
const r = store.send(from, to, body);
const out = { id: r.id, queuedAt: r.queuedAt.toISOString() };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
structuredContent: out,
};
},
);
server.registerTool(
"check_inbox",
{
title: "Check inbox",
description:
"Pull all undelivered messages for the current mailbox and mark them delivered. Returns an empty array when the inbox is empty.",
inputSchema: {},
},
async (_args, extra) => {
const name = requireSender(extra);
const messages = store.checkInbox(name).map((m) => {
const x = rowToMessage(m);
return { id: x.id, from: x.from, body: x.body, sentAt: x.sentAt.toISOString() };
});
return {
content: [{ type: "text", text: JSON.stringify(messages) }],
structuredContent: { messages },
};
},
);
server.registerTool(
"peek_inbox",
{
title: "Peek inbox",
description:
"Non-consuming check of the current mailbox. Returns pending count and oldest pending timestamp.",
inputSchema: {},
},
async (_args, extra) => {
const name = requireSender(extra);
const status = store.peek(name);
const out = { pending: status.pending, oldestAt: status.oldestAt?.toISOString() ?? null };
return {
content: [{ type: "text", text: JSON.stringify(out) }],
structuredContent: out,
};
},
);
server.registerTool(
"list_mailboxes",
{
title: "List mailboxes",
description: "Discover known mailboxes and how many messages each has waiting for you.",
inputSchema: {},
},
async (_args, extra) => {
const name = requireSender(extra);
const list = store.listMailboxes(name).map((m) => ({
name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,
}));
return {
content: [{ type: "text", text: JSON.stringify(list) }],
structuredContent: { mailboxes: list },
};
},
);
return server;
}
export async function registerMcp(app: FastifyInstance, store: MailboxStore): Promise<void> {
const mcpServer = buildMcpServer(store);
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await mcpServer.connect(transport);
const handle = async (req: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) => {
await transport.handleRequest(req.raw, reply.raw, req.body);
};
app.post("/mcp", handle);
app.get("/mcp", handle);
app.delete("/mcp", handle);
}

113
node/src/server.ts Normal file
View File

@@ -0,0 +1,113 @@
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { MailboxStore, rowToMessage } from "./db.js";
import type { DaemonConfig } from "./config.js";
import { registerMcp } from "./mcp.js";
export const HEADER_NAME = "x-mailbox";
declare module "fastify" {
interface FastifyRequest {
mailboxName?: string;
}
}
function readVersion(): string {
try {
const here = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")) as {
version?: string;
};
return pkg.version ?? "unknown";
} catch {
return "unknown";
}
}
const ANONYMOUS_PATHS = new Set(["/v1/list", "/v1/peek"]);
export async function buildServer(cfg: DaemonConfig, store: MailboxStore): Promise<FastifyInstance> {
const app = Fastify({ logger: true });
const version = readVersion();
app.addHook("onRequest", async (req: FastifyRequest, reply: FastifyReply) => {
const url = req.url.split("?")[0] ?? "/";
if (url === "/health" || url === "/mcp" || url.startsWith("/mcp/")) return;
const headerValue = req.headers[HEADER_NAME];
const name = (Array.isArray(headerValue) ? headerValue[0] : headerValue ?? "").trim();
if (!name) {
if (ANONYMOUS_PATHS.has(url)) return;
reply.code(400).send({ error: `Missing ${HEADER_NAME} header.` });
return reply;
}
req.mailboxName = name;
store.upsertMailbox(name);
});
app.get("/health", async () => ({
status: "ok",
version,
dbPath: cfg.dbPath,
}));
app.post<{ Body: { to?: string; body?: string } }>("/v1/send", async (req, reply) => {
const { to, body } = req.body ?? {};
if (!to || !body) {
reply.code(400);
return { error: "to and body are required" };
}
const from = req.mailboxName!;
const result = store.send(from, to, body);
return { id: result.id, queuedAt: result.queuedAt.toISOString() };
});
app.get<{ Querystring: { name?: string } }>("/v1/peek", async (req, reply) => {
const name = (req.query.name ?? "").trim();
if (!name) {
reply.code(400);
return { error: "name is required" };
}
const status = store.peek(name);
return {
pending: status.pending,
oldestAt: status.oldestAt?.toISOString() ?? null,
};
});
app.post<{ Querystring: { name?: string } }>("/v1/check-inbox", async (req, reply) => {
const name = (req.query.name ?? "").trim();
if (name !== req.mailboxName) {
reply.code(403);
return { error: "X-Mailbox header must match name." };
}
return store.checkInbox(name).map((m) => {
const msg = rowToMessage(m);
return { ...msg, sentAt: msg.sentAt.toISOString() };
});
});
app.get("/v1/list", async (req) => {
const name = req.mailboxName;
return store.listMailboxes(name).map((m) => ({
name: m.name,
lastSeenAt: m.lastSeenAt.toISOString(),
pendingForYou: m.pendingForYou,
}));
});
await registerMcp(app, store);
return app;
}
export async function startServer(cfg: DaemonConfig): Promise<{ app: FastifyInstance; store: MailboxStore }> {
const store = new MailboxStore(cfg.dbPath);
const app = await buildServer(cfg, store);
await app.listen({ host: cfg.bind, port: cfg.port });
return { app, store };
}

21
node/src/types/node-windows.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
declare module "node-windows" {
export interface ServiceOpts {
name: string;
description?: string;
script: string;
nodeOptions?: string[];
workingDirectory?: string;
}
export class Service {
constructor(opts: ServiceOpts);
on(
event: "install" | "uninstall" | "alreadyinstalled" | "start" | "stop" | "error",
cb: (err?: unknown) => void,
): void;
install(): void;
uninstall(): void;
start(): void;
stop(): void;
}
}

94
node/tests/db.test.ts Normal file
View File

@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MailboxStore } from "../src/db.js";
let dir: string;
let dbPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-test-"));
dbPath = join(dir, "test.db");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe("schema", () => {
it("creates fresh tables and is idempotent on re-open", () => {
const a = new MailboxStore(dbPath);
a.upsertMailbox("alice");
a.close();
const b = new MailboxStore(dbPath);
const list = b.listMailboxes();
expect(list.map((m) => m.name)).toEqual(["alice"]);
b.close();
});
});
describe("send / peek / check round-trip", () => {
it("delivers a message exactly once", () => {
const store = new MailboxStore(dbPath);
try {
const result = store.send("alice", "bob", "hello bob");
expect(result.id).toBeGreaterThan(0);
const peek1 = store.peek("bob");
expect(peek1.pending).toBe(1);
expect(peek1.oldestAt).toBeInstanceOf(Date);
const pulled = store.checkInbox("bob");
expect(pulled).toHaveLength(1);
expect(pulled[0]!.from_mailbox).toBe("alice");
expect(pulled[0]!.body).toBe("hello bob");
const peek2 = store.peek("bob");
expect(peek2.pending).toBe(0);
expect(peek2.oldestAt).toBeNull();
const empty = store.checkInbox("bob");
expect(empty).toEqual([]);
} finally {
store.close();
}
});
it("checkInbox returns all pending in order and marks them delivered atomically", () => {
const store = new MailboxStore(dbPath);
try {
for (let i = 0; i < 10; i++) {
store.send("alice", "bob", `msg ${i}`);
}
const first = store.checkInbox("bob");
expect(first).toHaveLength(10);
expect(first.map((m) => m.body)).toEqual(
Array.from({ length: 10 }, (_, i) => `msg ${i}`),
);
const second = store.checkInbox("bob");
expect(second).toEqual([]);
} finally {
store.close();
}
});
});
describe("listMailboxes", () => {
it("returns mailboxes alphabetically with pendingForYou for the caller", () => {
const store = new MailboxStore(dbPath);
try {
store.send("alice", "bob", "x");
store.send("alice", "bob", "y");
store.send("carol", "bob", "z");
const fromBob = store.listMailboxes("bob");
expect(fromBob.map((m) => m.name)).toEqual(["alice", "bob", "carol"]);
const bobRow = fromBob.find((m) => m.name === "bob");
expect(bobRow?.pendingForYou).toBe(3);
} finally {
store.close();
}
});
});

109
node/tests/server.test.ts Normal file
View File

@@ -0,0 +1,109 @@
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MailboxStore } from "../src/db.js";
import { buildServer } from "../src/server.js";
import type { FastifyInstance } from "fastify";
let dir: string;
let dbPath: string;
let store: MailboxStore;
let app: FastifyInstance;
let baseUrl: string;
beforeEach(async () => {
dir = mkdtempSync(join(tmpdir(), "claude-mailbox-srv-"));
dbPath = join(dir, "test.db");
store = new MailboxStore(dbPath);
app = await buildServer({ port: 0, bind: "127.0.0.1", dbPath }, store);
await app.listen({ host: "127.0.0.1", port: 0 });
const addr = app.server.address();
if (!addr || typeof addr === "string") throw new Error("no address");
baseUrl = `http://127.0.0.1:${addr.port}`;
});
afterEach(async () => {
await app.close();
store.close();
rmSync(dir, { recursive: true, force: true });
});
async function call(
method: string,
path: string,
init: { headers?: Record<string, string>; body?: unknown } = {},
): Promise<{ status: number; body: unknown }> {
const headers: Record<string, string> = { Accept: "application/json", ...(init.headers ?? {}) };
let body: string | undefined;
if (init.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(init.body);
}
const res = await fetch(`${baseUrl}${path}`, { method, headers, body });
const text = await res.text();
return { status: res.status, body: text.length ? JSON.parse(text) : null };
}
describe("REST surface", () => {
it("/health is anonymous", async () => {
const r = await call("GET", "/health");
expect(r.status).toBe(200);
expect(r.body).toMatchObject({ status: "ok", dbPath });
});
it("POST /v1/send requires X-Mailbox", async () => {
const r = await call("POST", "/v1/send", { body: { to: "bob", body: "hi" } });
expect(r.status).toBe(400);
});
it("POST /v1/send → /v1/check-inbox round-trip", async () => {
const send = await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "hi bob" },
});
expect(send.status).toBe(200);
expect(send.body).toMatchObject({ id: expect.any(Number), queuedAt: expect.any(String) });
const peek = await call("GET", "/v1/peek?name=bob");
expect(peek.status).toBe(200);
expect(peek.body).toMatchObject({ pending: 1 });
const check = await call("POST", "/v1/check-inbox?name=bob", {
headers: { "X-Mailbox": "bob" },
});
expect(check.status).toBe(200);
expect(Array.isArray(check.body)).toBe(true);
const arr = check.body as Array<{ from: string; body: string }>;
expect(arr).toHaveLength(1);
expect(arr[0]!.from).toBe("alice");
expect(arr[0]!.body).toBe("hi bob");
const peekAfter = await call("GET", "/v1/peek?name=bob");
expect(peekAfter.body).toMatchObject({ pending: 0, oldestAt: null });
});
it("POST /v1/check-inbox rejects mismatched X-Mailbox", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
const wrong = await call("POST", "/v1/check-inbox?name=bob", {
headers: { "X-Mailbox": "alice" },
});
expect(wrong.status).toBe(403);
});
it("/v1/list and /v1/peek are anonymous", async () => {
await call("POST", "/v1/send", {
headers: { "X-Mailbox": "alice" },
body: { to: "bob", body: "x" },
});
const list = await call("GET", "/v1/list");
expect(list.status).toBe(200);
expect(Array.isArray(list.body)).toBe(true);
const peek = await call("GET", "/v1/peek?name=bob");
expect(peek.status).toBe(200);
});
});

24
node/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true,
"strict": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist", "tests"]
}

9
node/vitest.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
testTimeout: 15_000,
pool: "forks",
},
});