3 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
mika kuns
757a095c10 fix(ci): use full GitHub URL for actions/checkout
All checks were successful
CI / build (push) Successful in 12s
Release / release (push) Successful in 7s
Gitea Actions resolves bare action names against the local Gitea
instance by default. Using the full github.com URL makes the runner
pull the action from upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:50:22 +02:00
26 changed files with 5644 additions and 42 deletions

View File

@@ -1,12 +1,24 @@
name: CI
name: CI (.NET)
on:
push:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "ClaudeMailbox.slnx"
- "global.json"
- ".gitea/workflows/ci-dotnet.yml"
pull_request:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "ClaudeMailbox.slnx"
- "global.json"
- ".gitea/workflows/ci-dotnet.yml"
jobs:
build:
@@ -15,7 +27,7 @@ jobs:
DOTNET_ROOT: /home/mika/.dotnet
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0

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

@@ -14,7 +14,7 @@ jobs:
REPO: releases/ClaudeMailbox
steps:
- name: Checkout tag
uses: actions/checkout@v4
uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0

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
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
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`.
## Daemon lifecycle
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>]
```sh
# macOS / Linux
curl -fsSL https://git.kuns.dev/releases/ClaudeMailbox/raw/branch/main/install.sh | sh
```
This:
- 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`
macOS users can also install via Homebrew once the tap is published:
Control:
```
claude-mailbox start
claude-mailbox stop
claude-mailbox status # prints Running | Stopped | NotInstalled
claude-mailbox uninstall-service [--purge]
```sh
brew install kuns/tap/claude-mailbox
```
`--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
@@ -69,21 +74,35 @@ claude-mailbox uninstall-service [--purge]
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
```
claude-mailbox install-service
sc query ClaudeMailbox
claude-mailbox start
Invoke-WebRequest http://127.0.0.1:47822/health
claude-mailbox uninstall-service --purge
```sh
claude-mailbox install-autostart
claude-mailbox status
curl http://127.0.0.1:47822/health
claude-mailbox uninstall-autostart --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

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",
},
});