Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d87d2aa7 |
@@ -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:
|
||||||
34
.gitea/workflows/ci-node.yml
Normal file
34
.gitea/workflows/ci-node.yml
Normal 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
|
||||||
116
.gitea/workflows/release-node.yml
Normal file
116
.gitea/workflows/release-node.yml
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
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
|
||||||
97
README.md
97
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
29
homebrew/claude-mailbox.rb
Normal file
29
homebrew/claude-mailbox.rb
Normal 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
46
install.ps1
Normal 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
28
install.sh
Normal 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
5
node/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
20
node/README.md
Normal file
20
node/README.md
Normal 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
3756
node/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
node/package.json
Normal file
55
node/package.json
Normal 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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
121
node/src/autostart/darwin.ts
Normal file
121
node/src/autostart/darwin.ts
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
59
node/src/autostart/index.ts
Normal file
59
node/src/autostart/index.ts
Normal 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
104
node/src/autostart/linux.ts
Normal 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";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
226
node/src/autostart/windows.ts
Normal file
226
node/src/autostart/windows.ts
Normal 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
204
node/src/cli.ts
Normal 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
91
node/src/config.ts
Normal 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
182
node/src/db.ts
Normal 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
121
node/src/mcp.ts
Normal 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
113
node/src/server.ts
Normal 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
21
node/src/types/node-windows.d.ts
vendored
Normal 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
94
node/tests/db.test.ts
Normal 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
109
node/tests/server.test.ts
Normal 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
24
node/tsconfig.json
Normal 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
9
node/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
testTimeout: 15_000,
|
||||||
|
pool: "forks",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user